File size: 9,365 Bytes
de36225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
import os
import asyncio
import io
import sys
import contextlib
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
from fastapi import File, UploadFile
import base64
import uuid
import uvicorn

# Worker that imports the agent and exposes a simple HTTP API
app = FastAPI(title="Agent Worker")

class QueryReq(BaseModel):
    question: str
    doc_id: Optional[str] = None
    mode: Optional[str] = None  # 'text2sql' or 'ocr_qa'


class UploadResp(BaseModel):
    doc_id: str
    text_preview: str
    image_url: str

class OcrQAReq(BaseModel):
    question: str
    doc_id: str

class OcrQAResp(BaseModel):
    doc_id: str
    answer: str
    used_llm: bool

# Lazy import agent module (do at startup so heavy init happens in worker)
agent = None
# Lock to prevent concurrent capture of stdout/stderr which would interleave logs
capture_lock = asyncio.Lock()

@app.on_event("startup")
async def startup_event():
    global agent
    # Import here so the LLM and DB init occur inside the worker process
    # Use package-relative import so we load Agent.agentic_workflow (not a top-level file)
    try:
        from . import agentic_workflow as agentic_workflow
    except Exception:
        # fallback to absolute package import
        import Agent.agentic_workflow as agentic_workflow
    agent = agentic_workflow
    # Initialize DB (best-effort)
    db_url = os.environ.get("DB_CONNECTION_URL")
    if db_url:
        try:
            await agent.initialize_database(uri=db_url)
        except Exception as e:
            # Log but continue; agentic_workflow has fallback behavior
            print(f"Worker: initialize_database failed: {e}")
    else:
        print("Worker: DB_CONNECTION_URL not set; skipping DB init")

@app.get("/health")
async def health():
    return {"status": "ok"}


@app.post("/query")
async def query(req: QueryReq):
    global agent
    if agent is None:
        raise HTTPException(status_code=503, detail="Agent not initialized")
    q = req.question
    doc_id = getattr(req, 'doc_id', None)
    mode = getattr(req, 'mode', None)
    # Capture stdout/stderr produced by the agent for this request
    buf_out = io.StringIO()
    buf_err = io.StringIO()
    async with capture_lock:
        try:
            with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
                # Robust mode switch: if mode is 'ocr_qa' or doc_id is present, use OCR agent; else use SQL logic
                if (mode == 'ocr_qa') or (doc_id and (mode is None or mode == 'auto')):
                    # Use OCR Q&A agent
                    # ocr_agent_qa may be sync, so run in executor
                    import asyncio
                    answer = await asyncio.get_event_loop().run_in_executor(
                        None, lambda: agent.ocr_agent_qa(q, doc_id)
                    )
                    result = answer
                    sql_out = None  # No SQL for OCR Q&A
                else:
                    # Use standard Text-to-SQL agentic workflow
                    result = await agent.agentic_query_process(q, None)
                    try:
                        sql_out = getattr(agent, 'LAST_GENERATED_SQL', None)
                    except Exception:
                        sql_out = None
        except ValueError as ve:
            # Treat known generation failures as normal answers so UI doesn't show nested JSON error
            err_msg = str(ve)
            if "Unable to generate SQL query" in err_msg:
                result = err_msg  # Provide user-visible plain text
                sql_out = None
            else:
                import traceback
                traceback.print_exc(file=buf_err)
                raise HTTPException(status_code=500, detail=err_msg)
        except Exception as e:
            import traceback
            traceback.print_exc(file=buf_err)
            raise HTTPException(status_code=500, detail=str(e))
    logs = buf_out.getvalue()
    errlogs = buf_err.getvalue()
    if errlogs:
        logs = logs + "\n" + errlogs
    return {"answer": result, "logs": logs, "sql": sql_out}


@app.post("/ocr-qa", response_model=OcrQAResp)
async def ocr_qa(req: OcrQAReq):
    """Answer a question about a previously uploaded document using its doc_id.

    This uses the agent's ocr_agent_qa helper which pulls stored OCR text and
    consults the LLM (or returns a low-memory fallback excerpt).
    """
    global agent
    if agent is None:
        raise HTTPException(status_code=503, detail="Agent not initialized")
    question = req.question
    doc_id = req.doc_id
    # Run potentially blocking OCR QA in thread pool if it's sync
    try:
        answer = await asyncio.get_event_loop().run_in_executor(
            None, lambda: agent.ocr_agent_qa(question, doc_id)
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"ocr_qa failed: {e}")
    used_llm = not answer.startswith("[Low memory]") and not answer.startswith("No OCR text") and not answer.startswith("Error while")
    return OcrQAResp(doc_id=doc_id, answer=answer, used_llm=used_llm)


@app.post("/upload-image", response_model=UploadResp)
async def upload_image(file: UploadFile = File(...)):
    """Accept an image, save locally, call MCP document_scanner.process eagerly,
    persist OCR to private chat store, and return a doc_id plus preview.
    """
    global agent
    if agent is None:
        raise HTTPException(status_code=503, detail="Agent not initialized")

    # Ensure uploads directory exists
    uploads_dir = "uploads"
    os.makedirs(uploads_dir, exist_ok=True)

    # Save uploaded file
    filename = f"{uuid.uuid4()}_{file.filename}"
    path = os.path.join(uploads_dir, filename)
    try:
        contents = await file.read()
        with open(path, "wb") as f:
            f.write(contents)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}")

    # Call MCP document_scanner tool eagerly with base64 payload to avoid exposing file server
    b64 = base64.b64encode(contents).decode('utf-8')
    try:
        from .mcp_client import mcp_call_tool
        ctx = {"tool_call": {"name": "document_scanner.process", "args": {"image_bytes": b64}}}
        resp = mcp_call_tool(prompt="run document scanner", timeout=60.0, context=ctx)
        # Expect tool_result in response
        tool_result = resp.get("tool_result") if isinstance(resp, dict) else None
    except Exception:
        # Fall back to local direct call if MCP unavailable or tool failed
        tool_result = None
        try:
            if agent and hasattr(agent, 'tools') and hasattr(agent.tools, 'document_scanner'):
                try:
                    tool_result = agent.tools.document_scanner.process_image(image_bytes=b64)
                except Exception:
                    tool_result = None
        except Exception:
            tool_result = None
        if not tool_result:
            tool_result = {"doc_id": str(uuid.uuid4()), "text": "(ocr unavailable)", "metadata": {}}

    # Persist OCR to private chat store file for retrieval by doc_id
    doc_id = tool_result.get("doc_id") if isinstance(tool_result, dict) and tool_result.get("doc_id") else str(uuid.uuid4())
    ocr_text = tool_result.get("text", "") if isinstance(tool_result, dict) else str(tool_result)

    # Save to docs/ and also to chat_store_private for history
    docs_dir = os.path.join("chat_store", "docs")
    os.makedirs(docs_dir, exist_ok=True)
    doc_path = os.path.join(docs_dir, f"{doc_id}.txt")
    try:
        with open(doc_path, "w", encoding="utf-8") as df:
            df.write(ocr_text)
    except Exception as e:
        print(f"Failed to persist OCR text: {e}")

    # Also persist the original image path so Granite Vision can do direct visual Q&A
    img_path_file = os.path.join(docs_dir, f"{doc_id}.img_path")
    try:
        abs_img_path = os.path.abspath(path)
        with open(img_path_file, "w", encoding="utf-8") as ipf:
            ipf.write(abs_img_path)
        print(f"[upload] Saved image path for doc_id={doc_id}: {abs_img_path}")
    except Exception as e:
        print(f"[upload] Failed to persist image path: {e}")

    # Also add a short entry to chat_store_private for traceability
    try:
        try:
            from .agentic_workflow import chat_store_private, ChatMessage, MessageRole
        except ImportError:
            from Agent.agentic_workflow import chat_store_private, ChatMessage, MessageRole
        # Create minimal messages
        user_msg = ChatMessage(role=MessageRole.USER, content=f"Uploaded document {doc_id}")
        assistant_msg = ChatMessage(role=MessageRole.ASSISTANT, content=f"OCR stored: {ocr_text[:400]}")
        chat_store_private.add_message(key="conversation", message=user_msg)
        chat_store_private.add_message(key="conversation", message=assistant_msg)
        chat_store_private.persist(str("chat_store/chat_store_private.json"))
    except Exception:
        pass

    # Return doc id and small preview
    preview = ocr_text[:400]
    image_url = path
    return UploadResp(doc_id=doc_id, text_preview=preview, image_url=image_url)

if __name__ == '__main__':
    port = int(os.environ.get("WORKER_PORT", 8700))
    uvicorn.run(app, host="127.0.0.1", port=port)