/**
 * src/knowledge/extraction.ts
 * استخراج الكيانات — Entity extraction from text using Qwen
 * Handles Arabic + English text, extracts structured entities and relationships
 */
// Phase 1: NIM-only via the nvidia provider. The qwen module is dead because
// Together AI's API key is the placeholder "mock_together_key" — extraction
// would silently 401, leaving the knowledge graph empty.
import { nvidia as qwen } from '../models/nvidia.js';
import { callWithFailover } from '../models/failover.js';
import { createChildLogger } from '../utils/logger.js';
const log = createChildLogger('knowledge:extraction');
// ─── Extraction Prompts ──────────────────────────────────────────────
const ENTITY_EXTRACTION_PROMPT = `You are the associative memory of a business AI — like a human brain forming connections. Read the text and extract the people, things, AND the SUBJECTS being discussed, then link them. The goal is a web of meaning that grows denser every conversation, so the agent can recall "we discussed X with this customer" and connect related chats across time.

IMPORTANT: Text may be Arabic, English, or mixed. Extract entities in their original language.

Return a JSON object with this EXACT structure:
{
  "entities": [
    { "name": "entity name (original language)", "type": "Person|Company|Product|Service|Topic|Need|Industry|Location|Price|Date|Event|Policy", "properties": { "key": "value" } }
  ],
  "relationships": [
    { "from": "entity name (must match an entity above)", "to": "entity name (must match an entity above)", "type": "discussed|interested_in|needs|purchased|recommends|referred_by|located_in|works_at|priced_at|valid_until|prefers|lives_in|competes_with|supplies|about|related_to|mentions|has_policy|offers", "properties": { "key": "value" } }
  ]
}

CRITICAL — always extract TOPICS and NEEDS, not just named things. The richest signal is WHAT the conversation was ABOUT:
- Topic: the subject/theme discussed — e.g. "website redesign", "تأخر التسليم" (delivery delay), "pricing", "SEO", "رخصة" (license). Extract 1-4 topics from EVERY conversation. This is what lets separate chats connect.
- Need: a problem the person wants solved or a goal — e.g. "needs faster delivery", "wants more leads".

Entity type guidelines:
- Person: REAL named individuals only. NEVER extract the assistant, the bot, or generic placeholders ("user", "web user", "customer", "Muassis", "المؤسس", "مستخدم", "العميل", "system", "assistant").
- Company: business names, organizations, suppliers, competitors.
- Product / Service: products, services, real-estate projects, packages, offerings.
- Topic: subjects/themes discussed (ALWAYS extract these).
- Need: a stated problem, requirement, or goal.
- Industry: sector/vertical (real estate, retail, F&B, contracting…).
- Location: cities, neighborhoods, addresses. Price: amounts (currency in properties). Date: dates/deadlines. Event: meetings/launches. Policy: rules/terms.

Relationship guidelines (link people and companies to the TOPICS/NEEDS they raised so chats connect):
- discussed: person/company → topic they talked about
- interested_in: person → product/service/topic they asked about
- needs: person/company → need they expressed
- about: event/document → topic
- recommends / competes_with / supplies: company relations
- purchased, referred_by, located_in, works_at, priced_at, valid_until, prefers, lives_in, related_to, mentions, has_policy, offers: as the names imply.

Extract generously but accurately. Prefer connecting an entity to a Topic over dropping it. Return ONLY valid JSON. No markdown, no explanations.`;
const CUSTOMER_INSIGHTS_PROMPT = `Analyze this customer conversation history and extract insights.

Return a JSON object with this exact structure:
{
  "preferences": ["list of customer preferences discovered"],
  "sentiment": "positive|neutral|negative|mixed",
  "intent": "what the customer is trying to achieve",
  "topics": ["main topics discussed"],
  "urgency": "low|medium|high"
}

The conversation may be in Arabic or English. Extract insights regardless of language.
Return ONLY valid JSON. No markdown, no explanations.`;
// ─── Core Functions ──────────────────────────────────────────────────
/**
 * Extract structured entities and relationships from text
 */
export async function extractEntities(text, tenantId) {
    if (!text || text.trim().length < 10) {
        return { entities: [], relationships: [] };
    }
    try {
        const messages = [
            { role: 'system', content: ENTITY_EXTRACTION_PROMPT },
            { role: 'user', content: `Extract entities and relationships from this text:\n\n${text}` },
        ];
        // Route through the SAME resilient failover path the chat brain uses
        // (honours the per-tenant model preference — e.g. kimi-k2.5). Calling
        // the nvidia client directly used its default model, which returned
        // empty on Ollama Cloud → 0 entities → the graph never grew. tenantId
        // is what unlocks the working model.
        const response = tenantId
            ? await callWithFailover('flash', messages, { temperature: 0.1, maxTokens: 2048 }, tenantId)
            : await qwen.chat(messages, { temperature: 0.1, maxTokens: 2048, responseFormat: { type: 'json_object' } });
        const parsed = parseJsonResponse(response.content);
        if (!parsed || !Array.isArray(parsed.entities)) {
            // (v3.0.1) Don't mark as failed — the regex stub extractor in
            // recordRawEpisode already grabbed the obvious entities synchronously.
            // Returning failed:true was triggering a journal_entries spam loop
            // (43 kg_extraction_failed entries in one session). Empty-result is
            // the correct outcome when the LLM gave us garbage.
            log.debug({ responseSnippet: response.content.slice(0, 200) }, 'extraction LLM returned non-JSON — falling back to stub entities only');
            return { entities: [], relationships: [] };
        }
        // Validate and clean entities. Drop the assistant/self and generic
        // placeholder "people" so the Person layer stays meaningful (these used
        // to pollute the graph: "Web user", "User", "Muassis" became Person nodes).
        const entities = parsed.entities
            .filter((e) => typeof e.name === 'string' &&
            typeof e.type === 'string' &&
            e.name.trim().length > 0)
            .map(e => ({
            name: e.name.trim(),
            type: validateEntityType(e.type),
            properties: e.properties ?? {},
        }))
            .filter((e) => !isNoiseEntity(e));
        // Validate relationships reference known entities
        const entityNames = new Set(entities.map(e => e.name));
        const relationships = (parsed.relationships ?? [])
            .filter((r) => typeof r.from === 'string' &&
            typeof r.to === 'string' &&
            typeof r.type === 'string' &&
            entityNames.has(r.from) &&
            entityNames.has(r.to))
            .map(r => ({
            from: r.from,
            to: r.to,
            type: validateRelationshipType(r.type),
            properties: r.properties ?? {},
        }));
        log.debug({ entityCount: entities.length, relCount: relationships.length }, 'Entities extracted');
        return { entities, relationships };
    }
    catch (err) {
        log.error({ error: err.message }, 'Entity extraction failed');
        // (C6) failed flag so caller can journal + alert; this used to be silent.
        return { entities: [], relationships: [], failed: true, reason: err.message };
    }
}
/**
 * Extract customer insights from conversation history
 */
export async function extractCustomerInsights(conversationHistory) {
    const defaultInsights = {
        preferences: [],
        sentiment: 'neutral',
        intent: 'unknown',
        topics: [],
        urgency: 'low',
    };
    if (!conversationHistory || conversationHistory.trim().length < 20) {
        return defaultInsights;
    }
    try {
        const messages = [
            { role: 'system', content: CUSTOMER_INSIGHTS_PROMPT },
            { role: 'user', content: `Analyze this conversation:\n\n${conversationHistory}` },
        ];
        const response = await qwen.chat(messages, {
            temperature: 0.1,
            maxTokens: 1024,
            responseFormat: { type: 'json_object' },
        });
        const parsed = parseJsonResponse(response.content);
        if (!parsed) {
            return defaultInsights;
        }
        return {
            preferences: Array.isArray(parsed.preferences) ? parsed.preferences : [],
            sentiment: validateSentiment(parsed.sentiment),
            intent: typeof parsed.intent === 'string' ? parsed.intent : 'unknown',
            topics: Array.isArray(parsed.topics) ? parsed.topics : [],
            urgency: validateUrgency(parsed.urgency),
        };
    }
    catch (err) {
        log.error({ error: err.message }, 'Customer insights extraction failed');
        return defaultInsights;
    }
}
// ─── Helpers ─────────────────────────────────────────────────────────
function parseJsonResponse(content) {
    // (v3.0.1) Multi-stage parser. Was failing on 43+ extraction attempts
    // because Kimi K2.5 wraps JSON in prose ("Here is the extraction: { ... }")
    // and the strict JSON.parse rejected. Now we:
    //   1. Strip markdown fences
    //   2. Extract the first balanced { ... } block
    //   3. Repair trailing commas / single quotes / unquoted keys
    //   4. Fall back to empty result instead of returning null (caller logs
    //      a single warning instead of journaling extraction_failed forever).
    let cleaned = (content ?? '').trim();
    if (cleaned.startsWith('```')) {
        cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
    }
    // First try: direct parse.
    try {
        return JSON.parse(cleaned);
    }
    catch { /* keep trying */ }
    // Second try: pull out the first balanced { ... } block.
    const firstBrace = cleaned.indexOf('{');
    const lastBrace = cleaned.lastIndexOf('}');
    if (firstBrace !== -1 && lastBrace > firstBrace) {
        const block = cleaned.slice(firstBrace, lastBrace + 1);
        try {
            return JSON.parse(block);
        }
        catch { /* keep trying */ }
        // Third try: repair common LLM JSON sins.
        const repaired = block
            // trailing comma before } or ]
            .replace(/,(\s*[}\]])/g, '$1')
            // smart quotes → straight
            .replace(/[“”]/g, '"')
            .replace(/[‘’]/g, "'");
        try {
            return JSON.parse(repaired);
        }
        catch { /* fall through */ }
    }
    log.warn({ snippet: cleaned.slice(0, 120) }, 'Failed to parse JSON response (after repair attempts)');
    return null;
}
const VALID_ENTITY_TYPES = new Set([
    'Person', 'Company', 'Product', 'Service', 'Topic', 'Need', 'Industry',
    'Location', 'Price', 'Date', 'Event', 'Policy',
]);
function validateEntityType(type) {
    if (VALID_ENTITY_TYPES.has(type))
        return type;
    // Fuzzy match (case-insensitive + a few common synonyms)
    const lower = String(type).toLowerCase();
    for (const valid of VALID_ENTITY_TYPES) {
        if (valid.toLowerCase() === lower)
            return valid;
    }
    const SYNONYMS = {
        organization: 'Company', org: 'Company', business: 'Company', brand: 'Company',
        people: 'Person', individual: 'Person', customer: 'Person', client: 'Person',
        service: 'Service', offering: 'Service',
        theme: 'Topic', subject: 'Topic', concept: 'Topic', category: 'Topic',
        problem: 'Need', goal: 'Need', requirement: 'Need', pain: 'Need',
        sector: 'Industry', vertical: 'Industry',
        place: 'Location', city: 'Location', address: 'Location',
        money: 'Price', amount: 'Price', cost: 'Price',
    };
    if (SYNONYMS[lower])
        return SYNONYMS[lower];
    // Unknown → Topic (a useful associative node) rather than Product (which
    // previously got polluted with every unclassified noun).
    return 'Topic';
}
// Names that must never become Person/Company nodes: the assistant itself and
// generic placeholders. Normalized (lowercased, Arabic "ال" stripped) compare.
const NOISE_NAMES = new Set([
    'muassis', 'al moasis', 'almoasis', 'moasis', 'مؤسس', 'المؤسس', 'مؤسّس',
    'user', 'web user', 'webuser', 'customer', 'client', 'guest', 'visitor',
    'مستخدم', 'العميل', 'عميل', 'الزبون', 'زائر',
    'assistant', 'ai', 'bot', 'system', 'agent', 'unknown', 'someone', 'me', 'you',
    'انا', 'أنا', 'انت', 'أنت', 'مساعد', 'النظام', 'الوكيل',
]);
function isNoiseEntity(e) {
    if (e.type !== 'Person' && e.type !== 'Company')
        return false;
    const norm = e.name.toLowerCase().replace(/^ال/, '').trim();
    return NOISE_NAMES.has(norm) || norm.length < 2;
}
const VALID_RELATIONSHIP_TYPES = new Set([
    'discussed', 'interested_in', 'needs', 'purchased', 'recommends', 'referred_by',
    'located_in', 'works_at', 'priced_at', 'valid_until', 'prefers', 'lives_in',
    'competes_with', 'supplies', 'about', 'related_to', 'mentions', 'has_policy', 'offers',
]);
function validateRelationshipType(type) {
    if (VALID_RELATIONSHIP_TYPES.has(type))
        return type;
    return 'related_to';
}
function validateSentiment(s) {
    const valid = new Set(['positive', 'neutral', 'negative', 'mixed']);
    return valid.has(s) ? s : 'neutral';
}
function validateUrgency(u) {
    const valid = new Set(['low', 'medium', 'high']);
    return valid.has(u) ? u : 'low';
}
//# sourceMappingURL=extraction.js.map