File size: 6,842 Bytes
af8ac78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""CrewAI tools β€” document ingestion, vector search, graph search, LLM synthesis."""
from __future__ import annotations
import json, uuid, sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from crewai.tools import tool
import config
from pipeline import document_loader, chunker, embedder, vector_store, graph_store
from agents.llm import get_llm


# ── Ingestion Tools ────────────────────────────────────────────────────────────

@tool("IngestDocumentTool")
def ingest_document(file_path: str) -> str:
    """Load, chunk, embed, and store a document in the vector database.
    Input: absolute path to the document file.
    Returns: ingestion summary string.
    """
    try:
        doc_id   = uuid.uuid4().hex[:8]
        docs     = document_loader.load_document(file_path)
        chunks   = chunker.chunk_documents(docs)
        if not chunks:
            return f"No text extracted from {file_path}"
        texts      = [c["text"] for c in chunks]
        embeddings = embedder.embed_texts(texts)
        session_token = config.current_session.get()
        added      = vector_store.add_chunks(chunks, embeddings, doc_id, tier="extended", session_token=session_token)
        return (f"Ingested '{os.path.basename(file_path)}': "
                f"{len(docs)} pages β†’ {added} chunks stored (id={doc_id})")
    except Exception as e:
        return f"Ingestion failed: {e}"


@tool("ExtractAndStoreEntitiesTool")
def extract_and_store_entities(file_path: str) -> str:
    """Extract key entities from a document and store in the graph database.
    Input: absolute path to the document file.
    Returns: entity extraction summary.
    """
    if not graph_store.is_available():
        return "Graph DB unavailable β€” skipped entity extraction."
    try:
        docs   = document_loader.load_document(file_path)
        source = os.path.basename(file_path)
        # Sample first 3 pages for entity extraction (avoid huge prompts)
        sample_text = "\n\n".join(d["text"] for d in docs[:3])[:3000]
        llm    = get_llm()
        prompt = (
            "Extract key entities from the text below.\n"
            "Return a JSON array of objects with keys: name, type, relations.\n"
            "type must be a broad category like: Person, Organization, Location, Concept, Event, Document, Object, Rule.\n"
            "relations is a list of {target, rel} objects.\n"
            "Return ONLY the JSON array, no explanation.\n\n"
            f"TEXT:\n{sample_text}\n\nJSON:"
        )
        raw = llm.call([{"role": "user", "content": prompt}])
        # Find JSON array in the response
        start = raw.find("[")
        end   = raw.rfind("]") + 1
        if start == -1 or end == 0:
            return "No entities extracted (LLM returned no JSON)."
        entities = json.loads(raw[start:end])
        session_token = config.current_session.get()
        graph_store.store_entities(entities, source, tier="extended", session_token=session_token)
        return f"Stored {len(entities)} entities from '{source}' in graph DB."
    except Exception as e:
        return f"Entity extraction failed: {e}"


# ── Retrieval Tools ────────────────────────────────────────────────────────────

@tool("VectorSearchTool")
def vector_search(query: str) -> str:
    """Search the vector database for relevant text chunks.
    Input: query string.
    Returns: formatted context passages with source citations.
    """
    try:
        q_emb   = embedder.embed_query(query)
        session_token = config.current_session.get()
        results = vector_store.query(q_emb, top_k=config.TOP_K_VECTOR, keyword=query, session_token=session_token)
        if not results:
            return "No relevant documents found in vector store."
        passages = []
        for i, r in enumerate(results, 1):
            src   = r["metadata"].get("source", "unknown")
            score = r["score"]
            passages.append(f"[{i}] (source: {src}, relevance: {score:.2f})\n{r['text']}")
        return "\n\n---\n\n".join(passages)
    except Exception as e:
        return f"Vector search failed: {e}"


@tool("GraphSearchTool")
def graph_search(entities: str) -> str:
    """Search the graph database for related entities.
    Input: comma-separated entity names.
    Returns: related entity context or unavailable message.
    """
    if not graph_store.is_available():
        return "Graph DB unavailable."
    try:
        names   = [e.strip() for e in entities.split(",") if e.strip()]
        session_token = config.current_session.get()
        related = graph_store.query_related(names, hops=2, session_token=session_token)
        if not related:
            return "No graph relationships found."
        return "Related entities from knowledge graph:\n" + "\n".join(f"- {r}" for r in related)
    except Exception as e:
        return f"Graph search failed: {e}"


# ── Synthesis Tool ─────────────────────────────────────────────────────────────

@tool("SynthesizeAnswerTool")
def synthesize_answer(context_and_query: str) -> str:
    """Synthesize a final answer from retrieved context.
    Input: JSON string with keys 'query' and 'context'.
    Returns: Markdown-formatted answer with citations.
    """
    try:
        data    = json.loads(context_and_query)
        query   = data.get("query", "")
        context = data.get("context", "")
    except Exception:
        query, context = context_and_query, ""

    llm    = get_llm()
    prompt = (
        "You are an expert Information Analyst.\n"
        "Your task is to answer the question using ONLY the provided context.\n"
        "CRITICAL INSTRUCTIONS:\n"
        "1. STRICT GROUNDING: You must not use any external knowledge. If the information is not present in the context, do not hallucinate or make assumptions.\n"
        "2. ZERO RETRIEVAL GUARDRAIL: If the provided context is empty, irrelevant, or does not contain the answer, you must output EXACTLY and ONLY this sentence:\n"
        "'Internal data does not have any information to answer the question.'\n"
        "3. FORMAT: If you can answer the question based on the context, format your response in Markdown with a clear structure, bullet points for key facts, source citations like [Source: filename], and a 'Summary' section at the end.\n\n"
        f"CONTEXT:\n{context}\n\n"
        f"QUESTION: {query}\n\n"
        "ANSWER:"
    )
    return llm.call([{"role": "user", "content": prompt}])