Spaces:
Sleeping
Sleeping
Upload 57 files
Browse filesInitial Commit For Deployment.
This view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +42 -0
- add_what_happened_column.py +54 -0
- agents/__init__.py +38 -0
- agents/coding_agent.py +410 -0
- agents/convo_agent.py +189 -0
- agents/debate_agent.py +64 -0
- agents/orchestrator_agent.py +299 -0
- agents/smart_orchestrator.py +64 -0
- core/__init__.py +12 -0
- core/__pycache__/__init__.cpython-310.pyc +0 -0
- core/__pycache__/app.cpython-310.pyc +0 -0
- core/__pycache__/auth.cpython-310.pyc +0 -0
- core/__pycache__/autogen_client.cpython-310.pyc +0 -0
- core/__pycache__/config.cpython-310.pyc +0 -0
- core/__pycache__/exceptions.cpython-310.pyc +0 -0
- core/__pycache__/llm_engine.cpython-310.pyc +0 -0
- core/__pycache__/middleware.cpython-310.pyc +0 -0
- core/app.py +82 -0
- core/auth.py +58 -0
- core/autogen_client.py +30 -0
- core/config.py +126 -0
- core/exceptions.py +54 -0
- core/llm_engine.py +35 -0
- create_pdf_id_index.py +26 -0
- delete.py +66 -0
- repositories/__init__.py +35 -0
- repositories/postgres_repo.py +521 -0
- repositories/qdrant_repo.py +333 -0
- requirements.txt +21 -0
- routers/__init__.py +8 -0
- routers/admin_router.py +181 -0
- routers/auth_router.py +53 -0
- routers/chat_router.py +550 -0
- routers/debate_router.py +74 -0
- routers/history_router.py +90 -0
- routers/pdf_router.py +86 -0
- routers/reflection_router.py +158 -0
- schemas/__init__.py +13 -0
- schemas/schema.py +86 -0
- services/__init__.py +41 -0
- services/agent_service.py +234 -0
- services/base_stream_service.py +111 -0
- services/context_injector.py +94 -0
- services/debate_service.py +324 -0
- services/deep_research_service.py +314 -0
- services/memory_service.py +122 -0
- services/rag_service.py +126 -0
- services/smart_orchestrator_service.py +623 -0
- test_db_connection.py +115 -0
- tests/__init__.py +1 -0
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE 1
|
| 6 |
+
ENV PYTHONUNBUFFERED 1
|
| 7 |
+
ENV PORT 7860
|
| 8 |
+
ENV HOME=/home/user
|
| 9 |
+
ENV TRANSFORMERS_CACHE=$HOME/.cache
|
| 10 |
+
|
| 11 |
+
# Create a non-root user
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
USER user
|
| 14 |
+
WORKDIR $HOME/app
|
| 15 |
+
|
| 16 |
+
# Set path
|
| 17 |
+
ENV PATH="/home/user/.local/bin:${PATH}"
|
| 18 |
+
|
| 19 |
+
# Install system dependencies (as root)
|
| 20 |
+
USER root
|
| 21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
build-essential \
|
| 23 |
+
libpq-dev \
|
| 24 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
+
USER user
|
| 26 |
+
|
| 27 |
+
# Install Python dependencies
|
| 28 |
+
COPY --chown=user requirements.txt .
|
| 29 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 30 |
+
pip install --no-cache-dir -r requirements.txt
|
| 31 |
+
|
| 32 |
+
# Pre-download the embedding model
|
| 33 |
+
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')"
|
| 34 |
+
|
| 35 |
+
# Copy the rest of the backend code
|
| 36 |
+
COPY --chown=user . .
|
| 37 |
+
|
| 38 |
+
# Expose the port the app runs on
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
# Run the application
|
| 42 |
+
CMD ["uvicorn", "core.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
add_what_happened_column.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from repositories.postgres_repo import get_pool
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
async def _column_exists(conn, table: str, column: str) -> bool:
|
| 6 |
+
return bool(
|
| 7 |
+
await conn.fetchval(
|
| 8 |
+
"""
|
| 9 |
+
SELECT EXISTS (
|
| 10 |
+
SELECT 1
|
| 11 |
+
FROM information_schema.columns
|
| 12 |
+
WHERE table_schema = current_schema()
|
| 13 |
+
AND table_name = $1
|
| 14 |
+
AND column_name = $2
|
| 15 |
+
)
|
| 16 |
+
""",
|
| 17 |
+
table,
|
| 18 |
+
column,
|
| 19 |
+
)
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def main():
|
| 24 |
+
pool = await get_pool()
|
| 25 |
+
async with pool.acquire() as conn:
|
| 26 |
+
has_pre_thinking = await _column_exists(conn, "messages", "pre_thinking")
|
| 27 |
+
has_what_happened = await _column_exists(conn, "messages", "what_happened")
|
| 28 |
+
|
| 29 |
+
if has_what_happened and not has_pre_thinking:
|
| 30 |
+
await conn.execute(
|
| 31 |
+
"ALTER TABLE messages RENAME COLUMN what_happened TO pre_thinking"
|
| 32 |
+
)
|
| 33 |
+
print("Renamed messages.what_happened to messages.pre_thinking")
|
| 34 |
+
elif has_what_happened and has_pre_thinking:
|
| 35 |
+
await conn.execute(
|
| 36 |
+
"""
|
| 37 |
+
UPDATE messages
|
| 38 |
+
SET pre_thinking = COALESCE(pre_thinking, what_happened)
|
| 39 |
+
WHERE what_happened IS NOT NULL
|
| 40 |
+
"""
|
| 41 |
+
)
|
| 42 |
+
await conn.execute("ALTER TABLE messages DROP COLUMN what_happened")
|
| 43 |
+
print("Merged data into pre_thinking and dropped what_happened")
|
| 44 |
+
elif not has_pre_thinking:
|
| 45 |
+
await conn.execute(
|
| 46 |
+
"ALTER TABLE messages ADD COLUMN pre_thinking JSONB DEFAULT NULL"
|
| 47 |
+
)
|
| 48 |
+
print("Added messages.pre_thinking")
|
| 49 |
+
else:
|
| 50 |
+
print("messages.pre_thinking already present; no changes needed")
|
| 51 |
+
await pool.close()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
asyncio.run(main())
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agents module — re-exports agent graph builders and node functions
|
| 2 |
+
from agents.convo_agent import (
|
| 3 |
+
get_tool_agent_graph,
|
| 4 |
+
agent_node,
|
| 5 |
+
tool_node,
|
| 6 |
+
should_continue,
|
| 7 |
+
run_tool_agent_stream,
|
| 8 |
+
)
|
| 9 |
+
from agents.orchestrator_agent import (
|
| 10 |
+
get_deep_research_graph,
|
| 11 |
+
deep_research_node,
|
| 12 |
+
parallel_researchers_node,
|
| 13 |
+
aggregator_node,
|
| 14 |
+
critic_node,
|
| 15 |
+
)
|
| 16 |
+
from agents.debate_agent import (
|
| 17 |
+
get_agent_a_persona,
|
| 18 |
+
get_agent_b_persona,
|
| 19 |
+
get_verifier_persona,
|
| 20 |
+
get_debate_model,
|
| 21 |
+
create_proposer_agent,
|
| 22 |
+
create_critic_agent,
|
| 23 |
+
create_verifier_agent,
|
| 24 |
+
)
|
| 25 |
+
from agents.coding_agent import (
|
| 26 |
+
code_planner_node,
|
| 27 |
+
parallel_coders_node,
|
| 28 |
+
code_aggregator_node,
|
| 29 |
+
code_reviewer_node,
|
| 30 |
+
should_retry,
|
| 31 |
+
format_output_node,
|
| 32 |
+
get_node_coords,
|
| 33 |
+
)
|
| 34 |
+
from agents.smart_orchestrator import (
|
| 35 |
+
classify_query,
|
| 36 |
+
get_standard_node_coords,
|
| 37 |
+
get_deep_research_node_coords,
|
| 38 |
+
)
|
agents/coding_agent.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import asyncio
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any, Callable
|
| 5 |
+
from core.llm_engine import get_llm
|
| 6 |
+
from core.config import AgentConfig
|
| 7 |
+
from schemas.schema import CodingAgentState, CodingSubtask
|
| 8 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 9 |
+
from utils.json_helpers import (
|
| 10 |
+
sanitize_fenced_json,
|
| 11 |
+
extract_first_json_object,
|
| 12 |
+
load_json_object,
|
| 13 |
+
clamp_score,
|
| 14 |
+
normalize_text,
|
| 15 |
+
normalize_errors,
|
| 16 |
+
normalize_serious_mistakes,
|
| 17 |
+
)
|
| 18 |
+
from utils.graph_nodes import get_coding_node_coords
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ─── Node Coordinates (for frontend graph) ────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
NODE_COORDS = get_coding_node_coords()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_node_coords() -> dict:
|
| 27 |
+
"""Return the node coordinate map for frontend graph rendering."""
|
| 28 |
+
return NODE_COORDS
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ─── Code Planner Node ───────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
async def code_planner_node(state: CodingAgentState) -> dict:
|
| 34 |
+
"""Decomposes the task into coding subtasks with shared interface."""
|
| 35 |
+
llm = get_llm(temperature=AgentConfig.CodingAgent.PLANNER_TEMPERATURE, instant=True)
|
| 36 |
+
task = state["original_task"]
|
| 37 |
+
|
| 38 |
+
prompt = f"""You are a senior software architect. Given the following coding task, break it into multiple independent subtasks that can be implemented in parallel.
|
| 39 |
+
|
| 40 |
+
Task: {task}
|
| 41 |
+
|
| 42 |
+
INSTRUCTIONS:
|
| 43 |
+
- Analyze what the task requires (could be any language: Python, HTML/CSS/JS, Java, etc.)
|
| 44 |
+
- Break it into Multiple logical, independent subtasks
|
| 45 |
+
- Each subtask should have a clear description of what it implements
|
| 46 |
+
- Include file names if the task naturally produces multiple files.
|
| 47 |
+
- The shared_contract should describe the overall structure/interfaces that all agents must respect
|
| 48 |
+
|
| 49 |
+
Respond in EXACTLY this JSON format (no other text):
|
| 50 |
+
{{
|
| 51 |
+
"subtasks": [
|
| 52 |
+
{{
|
| 53 |
+
"id": 1,
|
| 54 |
+
"description": "Clear description of what this subtask implements",
|
| 55 |
+
"signatures": ["Describe the key elements/functions this subtask should produce"]
|
| 56 |
+
}},
|
| 57 |
+
{{
|
| 58 |
+
"id": 2,
|
| 59 |
+
"description": "Clear description of what this subtask implements",
|
| 60 |
+
"signatures": ["Describe the key elements/functions this subtask should produce"]
|
| 61 |
+
}},
|
| 62 |
+
{{
|
| 63 |
+
"id": 3,
|
| 64 |
+
"description": "Clear description of what this subtask implements",
|
| 65 |
+
"signatures": ["Describe the key elements/functions this subtask should produce"]
|
| 66 |
+
}}
|
| 67 |
+
],
|
| 68 |
+
"shared_contract": "Overall structure and interfaces that all agents must respect"
|
| 69 |
+
}}
|
| 70 |
+
|
| 71 |
+
Each subtask should be independently implementable. The shared_contract must describe the overall structure so each coder knows how their work fits with others."""
|
| 72 |
+
|
| 73 |
+
response = await llm.ainvoke([
|
| 74 |
+
SystemMessage(content="You are a software architect. Output only valid JSON."),
|
| 75 |
+
HumanMessage(content=prompt),
|
| 76 |
+
])
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
content = response.content.strip()
|
| 80 |
+
if "```json" in content:
|
| 81 |
+
content = content.split("```json")[1].split("```")[0].strip()
|
| 82 |
+
elif "```" in content:
|
| 83 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 84 |
+
parsed = json.loads(content)
|
| 85 |
+
subtasks = parsed.get("subtasks", [])
|
| 86 |
+
shared_contract = parsed.get("shared_contract", "")
|
| 87 |
+
except Exception:
|
| 88 |
+
subtasks = [
|
| 89 |
+
{"id": 1, "description": f"Implement core data structures for: {task}", "signatures": ["Core structures and data types"]},
|
| 90 |
+
{"id": 2, "description": f"Implement main algorithm/logic for: {task}", "signatures": ["Main logic and algorithms"]},
|
| 91 |
+
{"id": 3, "description": f"Implement helper functions and utilities for: {task}", "signatures": ["Helper functions and utilities"]},
|
| 92 |
+
]
|
| 93 |
+
shared_contract = "All agents should implement their assigned parts with clear interfaces."
|
| 94 |
+
|
| 95 |
+
while len(subtasks) < 3:
|
| 96 |
+
subtasks.append({"id": len(subtasks) + 1, "description": f"Additional implementation for: {task}", "signatures": []})
|
| 97 |
+
|
| 98 |
+
coding_subtasks: list[CodingSubtask] = []
|
| 99 |
+
for st in subtasks[:3]:
|
| 100 |
+
coding_subtasks.append({
|
| 101 |
+
"id": st.get("id", len(coding_subtasks) + 1),
|
| 102 |
+
"description": st.get("description", ""),
|
| 103 |
+
"signatures": st.get("signatures", []),
|
| 104 |
+
"result": None,
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
return {
|
| 108 |
+
"subtasks": coding_subtasks,
|
| 109 |
+
"shared_contract": shared_contract,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ─── Parallel Coders Node ──────���─────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
async def parallel_coders_node(state: CodingAgentState) -> dict:
|
| 116 |
+
"""Runs 3 coding agents in parallel, each implementing its subtask."""
|
| 117 |
+
subtasks = state["subtasks"]
|
| 118 |
+
shared_contract = state["shared_contract"]
|
| 119 |
+
original_task = state["original_task"]
|
| 120 |
+
|
| 121 |
+
async def run_coder(subtask: CodingSubtask, coder_idx: int) -> str:
|
| 122 |
+
llm = get_llm(temperature=AgentConfig.CodingAgent.CODER_TEMPERATURE, change=False)
|
| 123 |
+
signatures_text = "\n".join(subtask.get("signatures", []))
|
| 124 |
+
|
| 125 |
+
prompt = f"""You are Coding Agent {coder_idx + 1}. You are part of a team implementing: {original_task}
|
| 126 |
+
|
| 127 |
+
YOUR SPECIFIC TASK:
|
| 128 |
+
{subtask['description']}
|
| 129 |
+
|
| 130 |
+
YOUR KEY ELEMENTS TO IMPLEMENT:
|
| 131 |
+
{signatures_text}
|
| 132 |
+
|
| 133 |
+
SHARED CONTRACT (overall structure all agents must respect):
|
| 134 |
+
{shared_contract}
|
| 135 |
+
|
| 136 |
+
IMPORTANT RULES:
|
| 137 |
+
1. Only implement the parts assigned to you
|
| 138 |
+
2. Follow the shared contract for naming, structure, and interfaces
|
| 139 |
+
3. Do NOT implement parts assigned to other agents — just reference them if needed
|
| 140 |
+
4. Write clean, well-documented code in whatever language best fits the task
|
| 141 |
+
5. Include comments explaining complex logic
|
| 142 |
+
6. After your code os written, provide a Detailed explanation separated by "---"
|
| 143 |
+
|
| 144 |
+
OUTPUT FORMAT:
|
| 145 |
+
Write your code first, then after a line with "---", write a brief explanation of what your code does and how it works.
|
| 146 |
+
|
| 147 |
+
Write your implementation:"""
|
| 148 |
+
|
| 149 |
+
response = await llm.ainvoke([
|
| 150 |
+
SystemMessage(content="You are a coding agent. Write clean, well-documented code in whatever language best fits the task. After your code, provide a brief explanation."),
|
| 151 |
+
HumanMessage(content=prompt),
|
| 152 |
+
])
|
| 153 |
+
return response.content
|
| 154 |
+
|
| 155 |
+
tasks = [run_coder(subtasks[i], i) for i in range(min(3, len(subtasks)))]
|
| 156 |
+
results = await asyncio.gather(*tasks)
|
| 157 |
+
|
| 158 |
+
cleaned_results = []
|
| 159 |
+
for result in results:
|
| 160 |
+
cleaned = re.sub(r'```\w*\n', '', result)
|
| 161 |
+
cleaned = cleaned.replace('```', '').strip()
|
| 162 |
+
cleaned_results.append(cleaned)
|
| 163 |
+
results = cleaned_results
|
| 164 |
+
|
| 165 |
+
while len(results) < 3:
|
| 166 |
+
results.append("# No implementation provided")
|
| 167 |
+
|
| 168 |
+
return {"coder_results": list(results)}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ─── Code Aggregator Node ─────────────────────────────────────────────────────
|
| 172 |
+
|
| 173 |
+
async def code_aggregator_node(state: CodingAgentState) -> dict:
|
| 174 |
+
"""Merges 3 coder outputs into one coherent codebase."""
|
| 175 |
+
llm = get_llm(temperature=AgentConfig.CodingAgent.AGGREGATOR_TEMPERATURE, change=True)
|
| 176 |
+
coder_results = state["coder_results"]
|
| 177 |
+
shared_contract = state["shared_contract"]
|
| 178 |
+
original_task = state["original_task"]
|
| 179 |
+
review_errors = state.get("review_errors", [])
|
| 180 |
+
|
| 181 |
+
coder_sections = "\n\n".join(
|
| 182 |
+
f"--- Coder {i+1} Output ---\n{result}" for i, result in enumerate(coder_results)
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
error_section = ""
|
| 186 |
+
if review_errors:
|
| 187 |
+
error_section = f"""
|
| 188 |
+
CRITICAL: The previous version had these errors that MUST be fixed:
|
| 189 |
+
{chr(10).join(f'- {e}' for e in review_errors)}
|
| 190 |
+
|
| 191 |
+
You MUST address all of these errors in your merged output.
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
prompt = f"""You are a senior code aggregator. Your job is to merge 3 coding agent outputs into a working, production-ready codebase.
|
| 195 |
+
|
| 196 |
+
Original Task: {original_task}
|
| 197 |
+
|
| 198 |
+
Shared Contract (overall structure all agents agreed on):
|
| 199 |
+
{shared_contract}
|
| 200 |
+
|
| 201 |
+
Coder Outputs (each coder includes code + explanation separated by "---"):
|
| 202 |
+
{coder_sections}
|
| 203 |
+
{error_section}
|
| 204 |
+
|
| 205 |
+
CRITICAL INSTRUCTIONS:
|
| 206 |
+
1. Output MULTIPLE files if the task naturally requires it
|
| 207 |
+
2. Use this EXACT format to separate files — NO markdown fences, ONLY use the file separator:
|
| 208 |
+
# === FILE: filename ===
|
| 209 |
+
[raw code content here, NO fences]
|
| 210 |
+
|
| 211 |
+
# === FILE: another_file ===
|
| 212 |
+
[raw code content here, NO fences]
|
| 213 |
+
|
| 214 |
+
3. Preserve the explanations from each coder. Write them in response in such manner that it preserve the Explantions as well as the Flow.
|
| 215 |
+
4. Do NOT add new functionality — only merge, fix references, and ensure interoperability
|
| 216 |
+
5. ABSOLUTELY DO NOT use markdown code fences (```) — output raw code only.
|
| 217 |
+
|
| 218 |
+
Merged code:"""
|
| 219 |
+
|
| 220 |
+
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
| 221 |
+
return {"merged_code": response.content}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ─── Code Reviewer Node ───────────────────────────────────────────────────────
|
| 225 |
+
|
| 226 |
+
async def code_reviewer_node(state: CodingAgentState) -> dict:
|
| 227 |
+
"""Reviews merged code for correctness and returns scores + errors."""
|
| 228 |
+
llm = get_llm(temperature=AgentConfig.CodingAgent.REVIEWER_TEMPERATURE, change=True)
|
| 229 |
+
merged_code = state["merged_code"]
|
| 230 |
+
original_task = state["original_task"]
|
| 231 |
+
retry_count = state.get("retry_count", 0)
|
| 232 |
+
|
| 233 |
+
prompt = f"""You are a strict code reviewer and quality gate.
|
| 234 |
+
|
| 235 |
+
Evaluate the merged code against the original task and report only concrete, high-value findings.
|
| 236 |
+
|
| 237 |
+
SCORING RUBRIC (integer 0-100):
|
| 238 |
+
- confidence: Correctness and production readiness of the implementation.
|
| 239 |
+
0-39 = fundamentally broken/incomplete; 40-69 = partially correct with major gaps;
|
| 240 |
+
70-89 = mostly correct with manageable issues; 90-100 = robust, correct, and production-ready.
|
| 241 |
+
- consistency: Internal coherence and contract alignment across files/interfaces.
|
| 242 |
+
0-39 = contradictory/incompatible; 40-69 = mixed integration quality;
|
| 243 |
+
70-89 = coherent with minor integration issues; 90-100 = cleanly integrated and consistent.
|
| 244 |
+
|
| 245 |
+
ISSUE CRITERIA:
|
| 246 |
+
- "errors": blocker defects that must be fixed before acceptance.
|
| 247 |
+
- "serious_mistakes": security vulnerabilities, requirement misses, data-loss risks,
|
| 248 |
+
broken interfaces, or logic errors with high user impact.
|
| 249 |
+
Every issue must be specific and actionable.
|
| 250 |
+
|
| 251 |
+
Original Task: {original_task}
|
| 252 |
+
|
| 253 |
+
Code to Review:
|
| 254 |
+
{merged_code}
|
| 255 |
+
|
| 256 |
+
Output contract (STRICT JSON ONLY; no markdown/backticks/preamble):
|
| 257 |
+
{{
|
| 258 |
+
"confidence": 0,
|
| 259 |
+
"consistency": 0,
|
| 260 |
+
"friendly_feedback": "2-4 sentence summary with concrete next steps.",
|
| 261 |
+
"errors": ["Actionable blocker 1", "Actionable blocker 2"],
|
| 262 |
+
"serious_mistakes": [
|
| 263 |
+
{{
|
| 264 |
+
"severity": "high|critical",
|
| 265 |
+
"description": "What is wrong and where it appears.",
|
| 266 |
+
"action": "Specific fix to apply."
|
| 267 |
+
}}
|
| 268 |
+
]
|
| 269 |
+
}}
|
| 270 |
+
If there are no blockers, return "errors": [].
|
| 271 |
+
If there are no serious mistakes, return "serious_mistakes": []."""
|
| 272 |
+
|
| 273 |
+
response = await llm.ainvoke([
|
| 274 |
+
SystemMessage(content="You are a data-formatting agent. Output raw JSON only."),
|
| 275 |
+
HumanMessage(content=prompt)
|
| 276 |
+
])
|
| 277 |
+
parse_error = ""
|
| 278 |
+
parsed: dict[str, Any] = {}
|
| 279 |
+
try:
|
| 280 |
+
parsed = load_json_object(response.content if isinstance(response.content, str) else str(response.content))
|
| 281 |
+
except ValueError as exc:
|
| 282 |
+
parse_error = str(exc)
|
| 283 |
+
|
| 284 |
+
confidence_default = 25 if parse_error else 70
|
| 285 |
+
consistency_default = 25 if parse_error else 70
|
| 286 |
+
confidence = clamp_score(parsed.get("confidence"), default=confidence_default)
|
| 287 |
+
consistency = clamp_score(
|
| 288 |
+
parsed.get("consistency", parsed.get("logical_consistency", parsed.get("consistency_score"))),
|
| 289 |
+
default=consistency_default,
|
| 290 |
+
)
|
| 291 |
+
errors = normalize_errors(parsed.get("errors", parsed.get("review_errors", [])))
|
| 292 |
+
if parse_error and not errors:
|
| 293 |
+
errors = [
|
| 294 |
+
"Reviewer output was not valid JSON. Re-run merge/review and verify every requirement explicitly."
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
feedback = normalize_text(parsed.get("friendly_feedback", parsed.get("critic_feedback")))
|
| 298 |
+
if not feedback:
|
| 299 |
+
feedback = (
|
| 300 |
+
"Reviewer output failed strict JSON validation; a conservative failure response was applied."
|
| 301 |
+
if parse_error
|
| 302 |
+
else "Review complete. Address blockers and rerun the reviewer."
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
serious_mistakes = normalize_serious_mistakes(parsed.get("serious_mistakes", []))
|
| 306 |
+
if parse_error and not serious_mistakes:
|
| 307 |
+
serious_mistakes = [
|
| 308 |
+
{
|
| 309 |
+
"severity": "high",
|
| 310 |
+
"description": "Reviewer response was not valid JSON, reducing trust in the quality gate.",
|
| 311 |
+
"action": "Regenerate reviewer output with strict JSON-only compliance.",
|
| 312 |
+
}
|
| 313 |
+
]
|
| 314 |
+
|
| 315 |
+
return {
|
| 316 |
+
"confidence_score": confidence,
|
| 317 |
+
"logical_consistency": consistency,
|
| 318 |
+
"review_errors": errors,
|
| 319 |
+
"critic_feedback": feedback,
|
| 320 |
+
"serious_mistakes": serious_mistakes,
|
| 321 |
+
"retry_count": retry_count + 1,
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ─── Retry Logic ──────────────────────────────────────────────────────────────
|
| 326 |
+
|
| 327 |
+
def should_retry(state: CodingAgentState) -> str:
|
| 328 |
+
"""Decide whether to retry aggregation or format final output."""
|
| 329 |
+
if state.get("review_errors") and state["retry_count"] < 2:
|
| 330 |
+
return "code_aggregator"
|
| 331 |
+
return "format_output"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# ─── Format Output Node ───────────────────────────────────────────────────────
|
| 335 |
+
|
| 336 |
+
def detect_language(content: str) -> str:
|
| 337 |
+
"""Detect the programming language from code content."""
|
| 338 |
+
content_lower = content.lower().strip()
|
| 339 |
+
if "<!doctype html>" in content_lower or "<html" in content_lower or "<form" in content_lower or "<div" in content_lower:
|
| 340 |
+
return "html"
|
| 341 |
+
if content.strip().startswith(("body {", ".class", "#id", "@keyframes", "@media")) or "{ color:" in content or "{ background:" in content:
|
| 342 |
+
return "css"
|
| 343 |
+
if "function " in content or "const " in content or "let " in content or "var " in content or "document." in content or "addEventListener" in content:
|
| 344 |
+
return "javascript"
|
| 345 |
+
if "public class " in content or "public static void main" in content or "import java." in content:
|
| 346 |
+
return "java"
|
| 347 |
+
if "def " in content or "import " in content or "class " in content or "print(" in content:
|
| 348 |
+
return "python"
|
| 349 |
+
return "python"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
async def format_output_node(state: CodingAgentState) -> dict:
|
| 353 |
+
"""Parses merged code into a structured list of {filename, content, language} dicts."""
|
| 354 |
+
merged_code = state["merged_code"]
|
| 355 |
+
|
| 356 |
+
# Strip any stray markdown fences first
|
| 357 |
+
if "```" in merged_code:
|
| 358 |
+
fenced_blocks = re.findall(r'```(\w*)\n(.*?)```', merged_code, re.DOTALL)
|
| 359 |
+
if fenced_blocks:
|
| 360 |
+
merged_code = "\n\n".join(block_content.strip() for _, block_content in fenced_blocks)
|
| 361 |
+
else:
|
| 362 |
+
merged_code = re.sub(r'```\w*\n?', '', merged_code).replace('```', '').strip()
|
| 363 |
+
|
| 364 |
+
parsed_files = []
|
| 365 |
+
file_separator = "# === FILE:"
|
| 366 |
+
|
| 367 |
+
if file_separator in merged_code:
|
| 368 |
+
file_blocks = merged_code.split(file_separator)
|
| 369 |
+
for file_block in file_blocks:
|
| 370 |
+
file_block = file_block.strip()
|
| 371 |
+
if not file_block:
|
| 372 |
+
continue
|
| 373 |
+
lines = file_block.split("\n")
|
| 374 |
+
filename = lines[0].strip().replace("===", "").strip()
|
| 375 |
+
if not filename:
|
| 376 |
+
continue
|
| 377 |
+
full_content = "\n".join(lines[1:])
|
| 378 |
+
# Strip explanation after "---"
|
| 379 |
+
file_content = full_content
|
| 380 |
+
if "\n---\n" in full_content:
|
| 381 |
+
file_content = re.split(r'\n---\n', full_content, maxsplit=1)[0].strip()
|
| 382 |
+
if file_content:
|
| 383 |
+
parsed_files.append({
|
| 384 |
+
"filename": filename,
|
| 385 |
+
"content": file_content,
|
| 386 |
+
"language": detect_language(file_content),
|
| 387 |
+
})
|
| 388 |
+
else:
|
| 389 |
+
all_blocks = re.findall(r'```(\w*)\n(.*?)```', merged_code, re.DOTALL)
|
| 390 |
+
ext_map = {"html": "index.html", "css": "styles.css", "javascript": "script.js", "python": "main.py", "java": "Main.java"}
|
| 391 |
+
if len(all_blocks) > 1:
|
| 392 |
+
for idx, (lang_hint, block_content) in enumerate(all_blocks):
|
| 393 |
+
block_content = block_content.strip()
|
| 394 |
+
if not block_content:
|
| 395 |
+
continue
|
| 396 |
+
lang = lang_hint if lang_hint else detect_language(block_content)
|
| 397 |
+
filename = ext_map.get(lang, f"file_{idx + 1}.{lang or 'txt'}")
|
| 398 |
+
parsed_files.append({"filename": filename, "content": block_content, "language": lang})
|
| 399 |
+
else:
|
| 400 |
+
# Single file fallback
|
| 401 |
+
code_content = merged_code.split("\n---\n", 1)[0].strip() if "\n---\n" in merged_code else merged_code
|
| 402 |
+
lang = detect_language(code_content)
|
| 403 |
+
parsed_files.append({
|
| 404 |
+
"filename": ext_map.get(lang, "output.txt"),
|
| 405 |
+
"content": code_content,
|
| 406 |
+
"language": lang,
|
| 407 |
+
})
|
| 408 |
+
|
| 409 |
+
return {"parsed_files": parsed_files}
|
| 410 |
+
|
agents/convo_agent.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import AsyncGenerator
|
| 3 |
+
from core.llm_engine import get_llm
|
| 4 |
+
from utils.tools import get_tools_list, get_tools_map
|
| 5 |
+
from schemas.schema import AgentState
|
| 6 |
+
from langchain_core.messages import HumanMessage, ToolMessage, AIMessage
|
| 7 |
+
from langgraph.graph import StateGraph, END
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def agent_node(state: AgentState):
|
| 13 |
+
"""LLM agent node: binds tools and invokes the model."""
|
| 14 |
+
llm = get_llm().bind_tools(get_tools_list())
|
| 15 |
+
response = llm.invoke(state["messages"])
|
| 16 |
+
return {"messages": [response]}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def tool_node(state: AgentState):
|
| 20 |
+
"""Execute tool calls from the last agent message."""
|
| 21 |
+
last_msg = state["messages"][-1]
|
| 22 |
+
tool_messages = []
|
| 23 |
+
for tc in last_msg.tool_calls:
|
| 24 |
+
tool_fn = get_tools_map().get(tc["name"])
|
| 25 |
+
if tool_fn:
|
| 26 |
+
try:
|
| 27 |
+
result = tool_fn.invoke(tc["args"])
|
| 28 |
+
tool_messages.append(
|
| 29 |
+
ToolMessage(content=str(result), tool_call_id=tc["id"])
|
| 30 |
+
)
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"[tool_node] Error invoking {tc['name']}: {e}")
|
| 33 |
+
tool_messages.append(
|
| 34 |
+
ToolMessage(content=f"Tool execution failed: {str(e)}", tool_call_id=tc["id"])
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
tool_messages.append(
|
| 38 |
+
ToolMessage(content="Tool not found.", tool_call_id=tc["id"])
|
| 39 |
+
)
|
| 40 |
+
return {"messages": tool_messages}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def should_continue(state: AgentState):
|
| 44 |
+
"""Route to tools if the agent made tool calls, otherwise end."""
|
| 45 |
+
last_msg = state["messages"][-1]
|
| 46 |
+
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
|
| 47 |
+
return "tools"
|
| 48 |
+
return END
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ─── Build Graph ──────────────────────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _build_graph():
|
| 55 |
+
graph = StateGraph(AgentState)
|
| 56 |
+
graph.add_node("agent", agent_node)
|
| 57 |
+
graph.add_node("tools", tool_node)
|
| 58 |
+
graph.set_entry_point("agent")
|
| 59 |
+
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
|
| 60 |
+
graph.add_edge("tools", "agent")
|
| 61 |
+
return graph.compile()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
_graph = _build_graph()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_tool_agent_graph():
|
| 68 |
+
"""Return the compiled LangGraph tool-calling agent graph."""
|
| 69 |
+
return _graph
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ─── Streaming Execution (3-Phase Async Generator) ───────────────────────────
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def run_tool_agent_stream(
|
| 76 |
+
messages: list,
|
| 77 |
+
) -> AsyncGenerator[dict, None]:
|
| 78 |
+
"""
|
| 79 |
+
Execute the tool-calling agent with real-time streaming (3-phase).
|
| 80 |
+
|
| 81 |
+
Yields event dicts:
|
| 82 |
+
{"type": "token", "content": "...", "phase": "initial"|"final"}
|
| 83 |
+
{"type": "tool_start", "tool_name": "...", "tool_args": {...}}
|
| 84 |
+
{"type": "tool_end", "tool_name": "...", "tool_output": "..."}
|
| 85 |
+
{"type": "complete", "answer": "...", "tools_used": [...], "messages": [...]}
|
| 86 |
+
|
| 87 |
+
3-Phase flow:
|
| 88 |
+
Phase 1: Stream the initial LLM response (text tokens or tool call decision)
|
| 89 |
+
Phase 2: Execute any tool calls locally
|
| 90 |
+
Phase 3: Stream the final LLM summary after tool results
|
| 91 |
+
"""
|
| 92 |
+
llm = get_llm().bind_tools(get_tools_list())
|
| 93 |
+
|
| 94 |
+
# ── Phase 1: Stream the initial response ──────────────────────────────
|
| 95 |
+
logger.info("[convo_agent] Phase 1: Streaming initial LLM response...")
|
| 96 |
+
first_response = None
|
| 97 |
+
|
| 98 |
+
for chunk in llm.stream(messages):
|
| 99 |
+
if first_response is None:
|
| 100 |
+
first_response = chunk
|
| 101 |
+
else:
|
| 102 |
+
first_response += chunk
|
| 103 |
+
|
| 104 |
+
if chunk.content:
|
| 105 |
+
yield {"type": "token", "content": chunk.content, "phase": "initial"}
|
| 106 |
+
|
| 107 |
+
messages.append(first_response)
|
| 108 |
+
has_tool_calls = bool(first_response.tool_calls)
|
| 109 |
+
logger.info(f"[convo_agent] Phase 1 complete. Tool calls: {has_tool_calls}")
|
| 110 |
+
|
| 111 |
+
# ── Phase 2: Execute tools if needed ──────────────────────────────────
|
| 112 |
+
tools_used = []
|
| 113 |
+
if has_tool_calls:
|
| 114 |
+
logger.info(
|
| 115 |
+
f"[convo_agent] Phase 2: Executing {len(first_response.tool_calls)} tool call(s)..."
|
| 116 |
+
)
|
| 117 |
+
for tc in first_response.tool_calls:
|
| 118 |
+
tool_name = tc["name"]
|
| 119 |
+
tool_args = tc["args"]
|
| 120 |
+
logger.info(
|
| 121 |
+
f"[convo_agent] Executing tool: {tool_name} with args: {tool_args}"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
yield {
|
| 125 |
+
"type": "tool_start",
|
| 126 |
+
"tool_name": tool_name,
|
| 127 |
+
"tool_args": tool_args,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
tool_fn = get_tools_map().get(tool_name)
|
| 131 |
+
if tool_fn:
|
| 132 |
+
try:
|
| 133 |
+
tool_output = tool_fn.invoke(tool_args)
|
| 134 |
+
logger.info(
|
| 135 |
+
f"[convo_agent] Tool {tool_name} returned: {str(tool_output)[:200]}..."
|
| 136 |
+
)
|
| 137 |
+
messages.append(
|
| 138 |
+
ToolMessage(content=str(tool_output), tool_call_id=tc["id"])
|
| 139 |
+
)
|
| 140 |
+
tools_used.append({"tool": tool_name, "args": tool_args})
|
| 141 |
+
|
| 142 |
+
yield {
|
| 143 |
+
"type": "tool_end",
|
| 144 |
+
"tool_name": tool_name,
|
| 145 |
+
"tool_output": str(tool_output),
|
| 146 |
+
}
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"[convo_agent] Tool {tool_name} execution error: {e}")
|
| 149 |
+
error_msg = f"Tool execution failed: {str(e)}"
|
| 150 |
+
messages.append(
|
| 151 |
+
ToolMessage(content=error_msg, tool_call_id=tc["id"])
|
| 152 |
+
)
|
| 153 |
+
yield {
|
| 154 |
+
"type": "tool_end",
|
| 155 |
+
"tool_name": tool_name,
|
| 156 |
+
"tool_output": error_msg,
|
| 157 |
+
}
|
| 158 |
+
else:
|
| 159 |
+
error_msg = f"Tool not found: {tool_name}"
|
| 160 |
+
logger.warning(f"[convo_agent] {error_msg}")
|
| 161 |
+
messages.append(ToolMessage(content=error_msg, tool_call_id=tc["id"]))
|
| 162 |
+
|
| 163 |
+
# ── Phase 3: Final stream (summary after tool results) ────────────────
|
| 164 |
+
initial_answer = first_response.content if first_response.content else ""
|
| 165 |
+
final_answer = ""
|
| 166 |
+
if has_tool_calls:
|
| 167 |
+
logger.info("[convo_agent] Phase 3: Streaming final LLM summary...")
|
| 168 |
+
for chunk in llm.stream(messages):
|
| 169 |
+
if chunk.content:
|
| 170 |
+
final_answer += chunk.content
|
| 171 |
+
yield {"type": "token", "content": chunk.content, "phase": "final"}
|
| 172 |
+
logger.info(
|
| 173 |
+
f"[convo_agent] Phase 3 complete. Final answer length: {len(final_answer)}"
|
| 174 |
+
)
|
| 175 |
+
else:
|
| 176 |
+
final_answer = initial_answer
|
| 177 |
+
logger.info(
|
| 178 |
+
f"[convo_agent] No tool calls needed. Answer length: {len(final_answer)}"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Combine initial + final answer for complete response
|
| 182 |
+
complete_answer = initial_answer + final_answer if has_tool_calls else final_answer
|
| 183 |
+
|
| 184 |
+
yield {
|
| 185 |
+
"type": "complete",
|
| 186 |
+
"answer": complete_answer,
|
| 187 |
+
"tools_used": tools_used,
|
| 188 |
+
"messages": messages,
|
| 189 |
+
}
|
agents/debate_agent.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from autogen_agentchat.agents import AssistantAgent
|
| 3 |
+
from core import get_autogen_groq_client
|
| 4 |
+
|
| 5 |
+
MODEL = "openai/gpt-oss-20b"
|
| 6 |
+
|
| 7 |
+
AGENT_A_PERSONA = """You are Agent Proposer, a confident debater who argues FOR the given topic.
|
| 8 |
+
You present compelling arguments, use evidence, and directly counter your opponent's points.
|
| 9 |
+
Be assertive but intellectually rigorous. Keep responses to 3-4 sentences max."""
|
| 10 |
+
|
| 11 |
+
AGENT_B_PERSONA = """You are Agent Critic, a sharp debater who argues AGAINST the given topic.
|
| 12 |
+
You challenge assumptions, present counterarguments, and expose weaknesses in opposing views.
|
| 13 |
+
Be direct and incisive. Keep responses to 3-4 sentences max."""
|
| 14 |
+
|
| 15 |
+
VERIFIER_PERSONA = """You are Agent Verifier, an impartial debate judge.
|
| 16 |
+
Evaluate both sides using argument quality, evidence quality, and internal consistency.
|
| 17 |
+
Return a concise verdict in 6-7 sentences, clearly stating which side argued better and why."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_agent_a_persona() -> str:
|
| 21 |
+
"""Return the Proposer persona."""
|
| 22 |
+
return AGENT_A_PERSONA
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_agent_b_persona() -> str:
|
| 26 |
+
"""Return the Critic persona."""
|
| 27 |
+
return AGENT_B_PERSONA
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_debate_model() -> str:
|
| 31 |
+
"""Return the debate model name."""
|
| 32 |
+
return MODEL
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_verifier_persona() -> str:
|
| 36 |
+
"""Return the verifier persona."""
|
| 37 |
+
return VERIFIER_PERSONA
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def create_proposer_agent() -> Any:
|
| 41 |
+
"""Create a proposer debate agent using AutoGen."""
|
| 42 |
+
return AssistantAgent(
|
| 43 |
+
name="agent_proposer",
|
| 44 |
+
model_client=get_autogen_groq_client(model=MODEL, temperature=0.7),
|
| 45 |
+
system_message=AGENT_A_PERSONA,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def create_critic_agent() -> Any:
|
| 50 |
+
"""Create a critic debate agent using AutoGen."""
|
| 51 |
+
return AssistantAgent(
|
| 52 |
+
name="agent_critic",
|
| 53 |
+
model_client=get_autogen_groq_client(model=MODEL, temperature=0.7),
|
| 54 |
+
system_message=AGENT_B_PERSONA,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def create_verifier_agent() -> Any:
|
| 59 |
+
"""Create a verifier debate agent using AutoGen."""
|
| 60 |
+
return AssistantAgent(
|
| 61 |
+
name="agent_verifier",
|
| 62 |
+
model_client=get_autogen_groq_client(model=MODEL, temperature=0.3),
|
| 63 |
+
system_message=VERIFIER_PERSONA,
|
| 64 |
+
)
|
agents/orchestrator_agent.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from typing import Any
|
| 4 |
+
from core.llm_engine import get_llm
|
| 5 |
+
from core.config import AgentConfig
|
| 6 |
+
from schemas.schema import OrchestratorState
|
| 7 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 8 |
+
from langgraph.graph import StateGraph, END
|
| 9 |
+
from utils.json_helpers import (
|
| 10 |
+
sanitize_fenced_json,
|
| 11 |
+
extract_first_json_object,
|
| 12 |
+
load_json_object,
|
| 13 |
+
clamp_score,
|
| 14 |
+
normalize_text,
|
| 15 |
+
normalize_serious_mistakes,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ─── Parallel Orchestrator (2 Researchers + Aggregator) ──────────────────────
|
| 20 |
+
|
| 21 |
+
def deep_research_node(state: OrchestratorState):
|
| 22 |
+
"""Analyzes the task and creates 2 researcher subtasks with different perspectives + 1 aggregator."""
|
| 23 |
+
task = state["original_task"]
|
| 24 |
+
llm = get_llm(temperature=AgentConfig.OrchestratorAgent.DECOMPOSER_TEMPERATURE)
|
| 25 |
+
|
| 26 |
+
prompt = f"""You are a task decomposer. Your job is to analyze the given task and break it into exactly 2 research assignments with different perspectives, plus 1 aggregation task.
|
| 27 |
+
|
| 28 |
+
IMPORTANT RULES:
|
| 29 |
+
- The context provided is ONLY for your background understanding. Do NOT include it in your output.
|
| 30 |
+
- Each researcher gets a DIFFERENT angle/perspective on the task.
|
| 31 |
+
- Researcher 1 should focus on facts, data, core concepts, and established knowledge.
|
| 32 |
+
- Researcher 2 should focus on alternative viewpoints, debates, controversies, and edge cases.
|
| 33 |
+
- Keep descriptions concise but specific to each researcher's angle.
|
| 34 |
+
- Output ONLY valid JSON.
|
| 35 |
+
|
| 36 |
+
Task: {task}
|
| 37 |
+
|
| 38 |
+
Respond in EXACTLY this JSON format (no markdown, no backticks):
|
| 39 |
+
{{
|
| 40 |
+
"researcher_1": "Specific research assignment for Researcher 1 focusing on core facts and data",
|
| 41 |
+
"researcher_2": "Specific research assignment for Researcher 2 focusing on alternative perspectives and debates"
|
| 42 |
+
}}"""
|
| 43 |
+
|
| 44 |
+
response = llm.invoke([
|
| 45 |
+
SystemMessage(content="You are a task decomposer. Output only valid JSON."),
|
| 46 |
+
HumanMessage(content=prompt),
|
| 47 |
+
])
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
content = response.content.strip()
|
| 51 |
+
if content.startswith("```json"):
|
| 52 |
+
content = content.replace("```json", "", 1)
|
| 53 |
+
if content.endswith("```"):
|
| 54 |
+
content = content[:-3]
|
| 55 |
+
content = content.strip()
|
| 56 |
+
parsed = json.loads(content)
|
| 57 |
+
r1_desc = parsed.get("researcher_1", f"Comprehensive research on: {task}")
|
| 58 |
+
r2_desc = parsed.get("researcher_2", f"Alternative perspectives on: {task}")
|
| 59 |
+
except Exception:
|
| 60 |
+
r1_desc = f"Comprehensive research on: {task}. Gather facts, data, background information, and existing analysis from reliable sources."
|
| 61 |
+
r2_desc = f"Alternative perspectives and debates on: {task}. Identify conflicting viewpoints, controversies, gaps in knowledge, and complementary information."
|
| 62 |
+
|
| 63 |
+
subtasks = [
|
| 64 |
+
{
|
| 65 |
+
"id": 1,
|
| 66 |
+
"description": r1_desc,
|
| 67 |
+
"agent_type": "researcher",
|
| 68 |
+
"result": None,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"id": 2,
|
| 72 |
+
"description": r2_desc,
|
| 73 |
+
"agent_type": "researcher",
|
| 74 |
+
"result": None,
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": 3,
|
| 78 |
+
"description": "Synthesize findings from both researchers into a comprehensive final report with the required 7-section structure.",
|
| 79 |
+
"agent_type": "aggregator",
|
| 80 |
+
"result": None,
|
| 81 |
+
},
|
| 82 |
+
]
|
| 83 |
+
logs = state.get("step_logs", [])
|
| 84 |
+
logs.append(f"🎯 Deep Research decomposed task into {len(subtasks)} subtasks (2x researcher + aggregator)")
|
| 85 |
+
return {"subtasks": subtasks, "step_logs": logs}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def parallel_researchers_node(state: OrchestratorState):
|
| 89 |
+
"""Executes all researcher subtasks in parallel and updates their results."""
|
| 90 |
+
subtasks = state["subtasks"]
|
| 91 |
+
researcher_indices = [i for i, st in enumerate(subtasks) if st["agent_type"] == "researcher"]
|
| 92 |
+
logs = list(state.get("step_logs", []))
|
| 93 |
+
logs.append(f"🚀 Launching {len(researcher_indices)} researcher agents in parallel")
|
| 94 |
+
|
| 95 |
+
original_task = state["original_task"]
|
| 96 |
+
|
| 97 |
+
async def run_researcher(description: str) -> str:
|
| 98 |
+
llm = get_llm(temperature=AgentConfig.OrchestratorAgent.RESEARCHER_TEMPERATURE)
|
| 99 |
+
prompt = f"""Original task: {original_task}
|
| 100 |
+
|
| 101 |
+
Your research assignment: {description}
|
| 102 |
+
|
| 103 |
+
Conduct thorough research and provide detailed findings. Include facts, data, sources, and any relevant context. Be comprehensive and precise."""
|
| 104 |
+
|
| 105 |
+
response = await llm.ainvoke([
|
| 106 |
+
SystemMessage(content="You are a research agent. Your job is to thoroughly research the given topic and provide comprehensive, unique, and factual information."),
|
| 107 |
+
HumanMessage(content=prompt),
|
| 108 |
+
])
|
| 109 |
+
return response.content
|
| 110 |
+
|
| 111 |
+
tasks = [run_researcher(subtasks[i]["description"]) for i in researcher_indices]
|
| 112 |
+
results = await asyncio.gather(*tasks)
|
| 113 |
+
|
| 114 |
+
new_subtasks = list(subtasks)
|
| 115 |
+
for idx, result in zip(researcher_indices, results):
|
| 116 |
+
st = dict(new_subtasks[idx])
|
| 117 |
+
st["result"] = result
|
| 118 |
+
new_subtasks[idx] = st
|
| 119 |
+
|
| 120 |
+
logs.append(f"✅ All {len(researcher_indices)} researchers completed")
|
| 121 |
+
return {"subtasks": new_subtasks, "step_logs": logs}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def aggregator_node(state: OrchestratorState):
|
| 125 |
+
"""Takes aggregator subtask and both researcher results, produces the final 7-section report."""
|
| 126 |
+
subtasks = state["subtasks"]
|
| 127 |
+
aggregator_subtask = next((st for st in subtasks if st["agent_type"] == "aggregator"), None)
|
| 128 |
+
logs = list(state.get("step_logs", []))
|
| 129 |
+
|
| 130 |
+
if not aggregator_subtask:
|
| 131 |
+
logs.append("❌ No aggregator subtask found")
|
| 132 |
+
return {"final_result": "Error: Aggregator agent missing.", "step_logs": logs}
|
| 133 |
+
|
| 134 |
+
researcher_results = [st["result"] for st in subtasks if st["agent_type"] == "researcher"]
|
| 135 |
+
researcher_texts = "\n\n".join(
|
| 136 |
+
f"--- Researcher {i+1} ---\n{res}" for i, res in enumerate(researcher_results)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
llm = get_llm(temperature=AgentConfig.OrchestratorAgent.AGGREGATOR_TEMPERATURE, change=True)
|
| 140 |
+
prompt = f"""You are a professional research writer. Synthesize the following research findings into a comprehensive, structured final report.
|
| 141 |
+
|
| 142 |
+
Original Task: {state['original_task']}
|
| 143 |
+
|
| 144 |
+
Research Inputs:
|
| 145 |
+
{researcher_texts}
|
| 146 |
+
|
| 147 |
+
Write a Bery High detailed report using EXACTLY these sections (use proper markdown headings):
|
| 148 |
+
|
| 149 |
+
1. Executive Summary
|
| 150 |
+
Provide a Detailed overview of the topic and main conclusions.
|
| 151 |
+
|
| 152 |
+
2. Key Findings
|
| 153 |
+
List the most important discoveries in bullet points. Also write the relatted things for the discoveries too.
|
| 154 |
+
|
| 155 |
+
3. Evidence From Sources
|
| 156 |
+
Present detailed evidence, data, and source citations.
|
| 157 |
+
|
| 158 |
+
4. Trends / Analysis
|
| 159 |
+
Analyze trends, patterns, implications, and provide insights. This Insight should be simple and Very Detailed.
|
| 160 |
+
|
| 161 |
+
5. Contradictions or Debates
|
| 162 |
+
Highlight any conflicting information, controversies, or areas of disagreement.
|
| 163 |
+
|
| 164 |
+
6. Conclusion
|
| 165 |
+
Summarize the key points and provide a forward-looking perspective.
|
| 166 |
+
|
| 167 |
+
7. Sources / Citations
|
| 168 |
+
List Major sources referenced in the research. If none were provided, note that.
|
| 169 |
+
|
| 170 |
+
Use clear markdown formatting with ## for major sections and ### for subsections where appropriate. Be comprehensive but avoid redundancy."""
|
| 171 |
+
|
| 172 |
+
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
| 173 |
+
logs.append("✍️ Aggregator agent synthesized final report with 7-section structure")
|
| 174 |
+
|
| 175 |
+
new_subtasks = list(subtasks)
|
| 176 |
+
for i, st in enumerate(new_subtasks):
|
| 177 |
+
if st["agent_type"] == "aggregator":
|
| 178 |
+
new_subtasks[i] = dict(st)
|
| 179 |
+
new_subtasks[i]["result"] = response.content
|
| 180 |
+
break
|
| 181 |
+
|
| 182 |
+
return {"final_result": response.content, "subtasks": new_subtasks, "step_logs": logs}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
async def critic_node(state: OrchestratorState):
|
| 186 |
+
"""Evaluates the aggregator's final output, assigns confidence and logical consistency scores."""
|
| 187 |
+
logs = list(state.get("step_logs", []))
|
| 188 |
+
final_result = state["final_result"]
|
| 189 |
+
|
| 190 |
+
llm = get_llm(temperature=AgentConfig.OrchestratorAgent.CRITIC_TEMPERATURE, change=True)
|
| 191 |
+
prompt = f"""You are a strict research quality critic.
|
| 192 |
+
|
| 193 |
+
Evaluate the report against the task and produce one JSON object only.
|
| 194 |
+
|
| 195 |
+
SCORING RUBRIC (integer 0-100):
|
| 196 |
+
- confidence: How trustworthy and well-supported the report is.
|
| 197 |
+
0-39 = major factual/support gaps; 40-69 = partial support with notable gaps;
|
| 198 |
+
70-89 = mostly supported and reliable; 90-100 = strongly supported, precise, and robust.
|
| 199 |
+
- consistency: Internal logic and alignment with the requested task/structure.
|
| 200 |
+
0-39 = contradictory or off-task; 40-69 = mixed coherence; 70-89 = coherent with minor issues;
|
| 201 |
+
90-100 = fully coherent, complete, and on-task.
|
| 202 |
+
|
| 203 |
+
SERIOUS MISTAKE CRITERIA:
|
| 204 |
+
Include only high-impact problems (fabricated claims, major missing sections, direct contradictions,
|
| 205 |
+
unsafe or misleading guidance, or conclusions unsupported by evidence).
|
| 206 |
+
Every serious mistake must describe the issue and a concrete corrective action.
|
| 207 |
+
|
| 208 |
+
Report:
|
| 209 |
+
{final_result}
|
| 210 |
+
|
| 211 |
+
Task:
|
| 212 |
+
{state['original_task']}
|
| 213 |
+
|
| 214 |
+
Output contract (STRICT JSON ONLY; no markdown/backticks/preamble):
|
| 215 |
+
{{
|
| 216 |
+
"confidence": 0,
|
| 217 |
+
"consistency": 0,
|
| 218 |
+
"friendly_feedback": "2-4 sentence actionable summary with at least one concrete next step.",
|
| 219 |
+
"serious_mistakes": [
|
| 220 |
+
{{
|
| 221 |
+
"severity": "high|critical",
|
| 222 |
+
"description": "What is wrong and where it appears.",
|
| 223 |
+
"action": "Specific fix the writer should apply."
|
| 224 |
+
}}
|
| 225 |
+
]
|
| 226 |
+
}}
|
| 227 |
+
If none, return "serious_mistakes": [] exactly."""
|
| 228 |
+
|
| 229 |
+
response = await llm.ainvoke([
|
| 230 |
+
SystemMessage(content="You are a Self-Reflective Critic agent. Output raw JSON only."),
|
| 231 |
+
HumanMessage(content=prompt)
|
| 232 |
+
])
|
| 233 |
+
logs.append("🔬 Critic agent evaluated output quality")
|
| 234 |
+
|
| 235 |
+
parse_error = ""
|
| 236 |
+
parsed: dict[str, Any] = {}
|
| 237 |
+
try:
|
| 238 |
+
parsed = load_json_object(response.content if isinstance(response.content, str) else str(response.content))
|
| 239 |
+
except ValueError as exc:
|
| 240 |
+
parse_error = str(exc)
|
| 241 |
+
logs.append(f"⚠️ Critic parsing error: {parse_error}")
|
| 242 |
+
|
| 243 |
+
confidence_default = 30 if parse_error else 70
|
| 244 |
+
consistency_default = 30 if parse_error else 70
|
| 245 |
+
confidence = clamp_score(parsed.get("confidence"), default=confidence_default)
|
| 246 |
+
consistency = clamp_score(
|
| 247 |
+
parsed.get("consistency", parsed.get("logical_consistency", parsed.get("consistency_score"))),
|
| 248 |
+
default=consistency_default,
|
| 249 |
+
)
|
| 250 |
+
feedback = normalize_text(parsed.get("friendly_feedback", parsed.get("critic_feedback")))
|
| 251 |
+
if not feedback:
|
| 252 |
+
feedback = (
|
| 253 |
+
"Critic output failed strict JSON validation. Re-run evaluation with strict format compliance."
|
| 254 |
+
if parse_error
|
| 255 |
+
else "Review complete. Address highlighted weaknesses to improve confidence and consistency."
|
| 256 |
+
)
|
| 257 |
+
serious_mistakes = normalize_serious_mistakes(parsed.get("serious_mistakes", []))
|
| 258 |
+
if parse_error and not serious_mistakes:
|
| 259 |
+
serious_mistakes = [
|
| 260 |
+
{
|
| 261 |
+
"severity": "high",
|
| 262 |
+
"description": "Critic output was not valid JSON, so quality scoring may be unreliable.",
|
| 263 |
+
"action": "Re-run critic with strict JSON-only output and reassess the report.",
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
return {
|
| 268 |
+
"critic_confidence": confidence,
|
| 269 |
+
"critic_logical_consistency": consistency,
|
| 270 |
+
"critic_feedback": feedback,
|
| 271 |
+
"serious_mistakes": serious_mistakes,
|
| 272 |
+
"step_logs": logs,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ─── Build Graph ──────────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
def _build_deep_research_graph():
|
| 279 |
+
graph = StateGraph(OrchestratorState)
|
| 280 |
+
graph.add_node("deep_research", deep_research_node)
|
| 281 |
+
graph.add_node("parallel_researchers", parallel_researchers_node)
|
| 282 |
+
graph.add_node("aggregator", aggregator_node)
|
| 283 |
+
graph.add_node("critic", critic_node)
|
| 284 |
+
|
| 285 |
+
graph.set_entry_point("deep_research")
|
| 286 |
+
graph.add_edge("deep_research", "parallel_researchers")
|
| 287 |
+
graph.add_edge("parallel_researchers", "aggregator")
|
| 288 |
+
graph.add_edge("aggregator", "critic")
|
| 289 |
+
graph.add_edge("critic", END)
|
| 290 |
+
|
| 291 |
+
return graph.compile()
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
_deep_research_graph = _build_deep_research_graph()
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def get_deep_research_graph():
|
| 298 |
+
"""Return the compiled deep_research graph."""
|
| 299 |
+
return _deep_research_graph
|
agents/smart_orchestrator.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from core.llm_engine import get_llm
|
| 2 |
+
from core.config import AgentConfig
|
| 3 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 4 |
+
from utils.graph_nodes import get_standard_node_coords, get_deep_research_node_coords
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ─── Node Coordinates for Standard & Deep Research Paths ──────────────────────
|
| 8 |
+
|
| 9 |
+
STANDARD_NODE_COORDS = get_standard_node_coords()
|
| 10 |
+
DEEP_RESEARCH_NODE_COORDS = get_deep_research_node_coords()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_standard_node_coords() -> dict:
|
| 14 |
+
"""Return node coordinates for the standard path."""
|
| 15 |
+
return STANDARD_NODE_COORDS
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_deep_research_node_coords() -> dict:
|
| 19 |
+
"""Return node coordinates for the deep research path."""
|
| 20 |
+
return DEEP_RESEARCH_NODE_COORDS
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ─── Query Classifier ─────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
async def classify_query(task: str) -> tuple[str, str, str]:
|
| 26 |
+
"""Classify the task into one of three paths and return problem understanding for code tasks."""
|
| 27 |
+
llm = get_llm(temperature=AgentConfig.SmartOrchestrator.ROUTER_TEMPERATURE, instant=True)
|
| 28 |
+
|
| 29 |
+
prompt = f"""You are a query router. Classify the following user query into exactly one category.
|
| 30 |
+
|
| 31 |
+
Query: {task}
|
| 32 |
+
|
| 33 |
+
Categories:
|
| 34 |
+
- standard: Simple factual as well as the Detailed questions, greetings, quick calculations, single-step as well as multi step tasks. Use this In most of normal QA.
|
| 35 |
+
- deep_research: Questions requiring very hard multi-perspective research, analysis, comparisons, explanations of complex topics, if the topic needs very good research then select this mode.
|
| 36 |
+
- code: Requests to write, implement, debug, or generate code, algorithms, data structures
|
| 37 |
+
|
| 38 |
+
Respond in EXACTLY this format:
|
| 39 |
+
PATH: [standard|deep_research|code]
|
| 40 |
+
UNDERSTANDING: [If PATH is code, provide a brief 2-3 sentence problem understanding explaining what the problem asks for and what the expected solution should look like. If PATH is not code, write "N/A"]
|
| 41 |
+
REASON: [brief explanation of why this path was chosen]"""
|
| 42 |
+
|
| 43 |
+
response = await llm.ainvoke([
|
| 44 |
+
SystemMessage(content="You are a query classifier. Output only the specified format."),
|
| 45 |
+
HumanMessage(content=prompt),
|
| 46 |
+
])
|
| 47 |
+
|
| 48 |
+
content = response.content.strip()
|
| 49 |
+
path = "standard"
|
| 50 |
+
reason = "Default classification"
|
| 51 |
+
problem_understanding = "N/A"
|
| 52 |
+
|
| 53 |
+
for line in content.split("\n"):
|
| 54 |
+
line = line.strip()
|
| 55 |
+
if line.startswith("PATH:"):
|
| 56 |
+
extracted = line.split(":")[1].strip().lower()
|
| 57 |
+
if extracted in ("standard", "deep_research", "code"):
|
| 58 |
+
path = extracted
|
| 59 |
+
elif line.startswith("UNDERSTANDING:"):
|
| 60 |
+
problem_understanding = line.split(":", 1)[1].strip()
|
| 61 |
+
elif line.startswith("REASON:"):
|
| 62 |
+
reason = line.split(":", 1)[1].strip()
|
| 63 |
+
|
| 64 |
+
return path, reason, problem_understanding
|
core/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core module — re-exports commonly used items
|
| 2 |
+
# NOTE: app is NOT imported here to avoid circular imports.
|
| 3 |
+
# Import app directly: from core.app import app
|
| 4 |
+
from core.llm_engine import get_llm, get_client
|
| 5 |
+
from core.autogen_client import get_autogen_groq_client
|
| 6 |
+
from core.auth import hash_password, verify_password, create_token, decode_token, get_current_user
|
| 7 |
+
from core.exceptions import (
|
| 8 |
+
DatabaseError,
|
| 9 |
+
LLMError,
|
| 10 |
+
QdrantError,
|
| 11 |
+
register_exception_handlers,
|
| 12 |
+
)
|
core/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (565 Bytes). View file
|
|
|
core/__pycache__/app.cpython-310.pyc
ADDED
|
Binary file (2.2 kB). View file
|
|
|
core/__pycache__/auth.cpython-310.pyc
ADDED
|
Binary file (2.17 kB). View file
|
|
|
core/__pycache__/autogen_client.cpython-310.pyc
ADDED
|
Binary file (855 Bytes). View file
|
|
|
core/__pycache__/config.cpython-310.pyc
ADDED
|
Binary file (3.93 kB). View file
|
|
|
core/__pycache__/exceptions.cpython-310.pyc
ADDED
|
Binary file (2.43 kB). View file
|
|
|
core/__pycache__/llm_engine.cpython-310.pyc
ADDED
|
Binary file (1.3 kB). View file
|
|
|
core/__pycache__/middleware.cpython-310.pyc
ADDED
|
Binary file (2.7 kB). View file
|
|
|
core/app.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from core.exceptions import register_exception_handlers
|
| 4 |
+
from repositories import init_db, close_pool, init_qdrant_collections
|
| 5 |
+
from routers import (
|
| 6 |
+
auth_router,
|
| 7 |
+
chat_router,
|
| 8 |
+
history_router,
|
| 9 |
+
pdf_router,
|
| 10 |
+
debate_router,
|
| 11 |
+
admin_router,
|
| 12 |
+
reflection_router
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
app = FastAPI(title="AI Agents Platform")
|
| 16 |
+
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=["*"],
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Register global exception handlers
|
| 25 |
+
register_exception_handlers(app)
|
| 26 |
+
|
| 27 |
+
# Include all routers
|
| 28 |
+
app.include_router(auth_router)
|
| 29 |
+
app.include_router(chat_router)
|
| 30 |
+
app.include_router(history_router)
|
| 31 |
+
app.include_router(pdf_router)
|
| 32 |
+
app.include_router(debate_router)
|
| 33 |
+
app.include_router(admin_router)
|
| 34 |
+
app.include_router(reflection_router)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ─── Startup / Shutdown Events ────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
@app.on_event("startup")
|
| 40 |
+
async def startup():
|
| 41 |
+
"""Initialize database and Qdrant collections on server start."""
|
| 42 |
+
await init_db()
|
| 43 |
+
await close_pool()
|
| 44 |
+
try:
|
| 45 |
+
init_qdrant_collections()
|
| 46 |
+
print("[app] Qdrant collections initialized.")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"[app] Qdrant initialization skipped (unavailable): {e}")
|
| 49 |
+
print("[app] Server started successfully.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@app.on_event("shutdown")
|
| 53 |
+
async def shutdown():
|
| 54 |
+
"""Close database pool on shutdown."""
|
| 55 |
+
await close_pool()
|
| 56 |
+
print("[app] Server shut down.")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ─── Root ──────────────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
@app.get("/")
|
| 62 |
+
async def root():
|
| 63 |
+
"""Root endpoint - API landing page"""
|
| 64 |
+
return {
|
| 65 |
+
"service": "Agentrix.io API",
|
| 66 |
+
"status": "operational",
|
| 67 |
+
"version": "2.6.0",
|
| 68 |
+
"endpoints": {
|
| 69 |
+
"auth_register": "/auth/register (POST)",
|
| 70 |
+
"auth_login": "/auth/login (POST)",
|
| 71 |
+
"chat": "/chat (POST)",
|
| 72 |
+
"orchestrator": "/orchestrator/task (POST)",
|
| 73 |
+
"smart_orchestrator": "/smart-orchestrator/stream (POST)",
|
| 74 |
+
"debate": "/debate/stream (GET)",
|
| 75 |
+
"upload": "/upload-pdf (POST)",
|
| 76 |
+
"memory_pdfs": "/memory/pdfs (GET)",
|
| 77 |
+
"history": "/history (GET)",
|
| 78 |
+
"reflection": "/reflection/summary (GET)",
|
| 79 |
+
"docs": "/docs",
|
| 80 |
+
"redoc": "/redoc",
|
| 81 |
+
},
|
| 82 |
+
}
|
core/auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from fastapi import HTTPException, Header
|
| 6 |
+
from jose import JWTError, jwt
|
| 7 |
+
import bcrypt
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
# ─── Password Hashing ─────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def hash_password(plain: str) -> str:
|
| 15 |
+
"""Hash a plaintext password using bcrypt."""
|
| 16 |
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 20 |
+
"""Verify a plaintext password against a bcrypt hash."""
|
| 21 |
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ─── JWT Token ────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
JWT_SECRET = os.getenv("JWT_SECRET", "agentrix-default-secret-change-me")
|
| 27 |
+
JWT_ALGORITHM = "HS256"
|
| 28 |
+
JWT_EXPIRY_DAYS = 7
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_token(user_id: str) -> str:
|
| 32 |
+
"""Create a JWT token with 7-day expiry."""
|
| 33 |
+
payload = {
|
| 34 |
+
"sub": user_id,
|
| 35 |
+
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY_DAYS),
|
| 36 |
+
"iat": datetime.now(timezone.utc),
|
| 37 |
+
}
|
| 38 |
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def decode_token(token: str) -> str:
|
| 42 |
+
"""Decode a JWT token and return the user_id. Raises HTTPException on failure."""
|
| 43 |
+
try:
|
| 44 |
+
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
| 45 |
+
user_id: str = payload.get("sub")
|
| 46 |
+
if user_id is None:
|
| 47 |
+
raise HTTPException(status_code=401, detail="Invalid token: missing subject")
|
| 48 |
+
return user_id
|
| 49 |
+
except JWTError:
|
| 50 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def get_current_user(authorization: str = Header(...)) -> str:
|
| 54 |
+
"""FastAPI dependency: extracts and validates Bearer token from Authorization header."""
|
| 55 |
+
if not authorization.startswith("Bearer "):
|
| 56 |
+
raise HTTPException(status_code=401, detail="Invalid authorization header format")
|
| 57 |
+
token = authorization.split(" ", 1)[1]
|
| 58 |
+
return decode_token(token)
|
core/autogen_client.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from autogen_ext.models.openai import OpenAIChatCompletionClient
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
GROQ_OPENAI_BASE_URL = "https://api.groq.com/openai/v1"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_autogen_groq_client(model: str, temperature: float = 0.7) -> OpenAIChatCompletionClient:
|
| 10 |
+
"""Create an AutoGen-compatible OpenAI client targeting Groq."""
|
| 11 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 12 |
+
if not api_key:
|
| 13 |
+
raise ValueError("GROQ_API_KEY is required for AutoGen debate execution.")
|
| 14 |
+
|
| 15 |
+
# AutoGen 0.4 requires model_info for non-standard OpenAI model names.
|
| 16 |
+
# We provide standard capabilities for Groq-hosted models.
|
| 17 |
+
model_info = {
|
| 18 |
+
"vision": False,
|
| 19 |
+
"function_calling": True,
|
| 20 |
+
"json_output": True,
|
| 21 |
+
"family": "gpt-4", # Using gpt-4 family as a safe baseline for capabilities
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return OpenAIChatCompletionClient(
|
| 25 |
+
model=model,
|
| 26 |
+
api_key=api_key,
|
| 27 |
+
base_url=GROQ_OPENAI_BASE_URL,
|
| 28 |
+
temperature=temperature,
|
| 29 |
+
model_info=model_info,
|
| 30 |
+
)
|
core/config.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized configuration module for LLM parameters, scoring, and constants.
|
| 3 |
+
|
| 4 |
+
Provides single source of truth for:
|
| 5 |
+
- LLM temperature profiles for different use cases
|
| 6 |
+
- Scoring and confidence thresholds
|
| 7 |
+
- Valid severity levels for mistakes/issues
|
| 8 |
+
- Graph node coordinates (eventually)
|
| 9 |
+
- Agent-specific parameters
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
from core.config import LLMConfig, ScoringConfig
|
| 13 |
+
|
| 14 |
+
llm = get_llm(temperature=LLMConfig.PRECISE)
|
| 15 |
+
score = clamp_score(value, default=ScoringConfig.DEFAULT)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class LLMConfig:
|
| 20 |
+
"""
|
| 21 |
+
LLM temperature profiles for different use cases.
|
| 22 |
+
|
| 23 |
+
Temperature controls randomness: 0 = deterministic, 1 = highly random.
|
| 24 |
+
- Structured: Planning, routing, critics (need precision)
|
| 25 |
+
- Precise: Code generation, technical writing (mostly deterministic)
|
| 26 |
+
- Balanced: Research, analysis, general reasoning (mix of precision & exploration)
|
| 27 |
+
- Creative: Brainstorming, alternatives (high exploration)
|
| 28 |
+
"""
|
| 29 |
+
# Planning & Decision Making (deterministic)
|
| 30 |
+
STRUCTURED = 0.0
|
| 31 |
+
|
| 32 |
+
# Code & Technical Output (precise but not rigid)
|
| 33 |
+
PRECISE = 0.1
|
| 34 |
+
|
| 35 |
+
# Research & Analysis (balanced exploration + precision)
|
| 36 |
+
BALANCED = 0.3
|
| 37 |
+
|
| 38 |
+
# Brainstorming & Alternatives (exploratory)
|
| 39 |
+
CREATIVE = 0.7
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ScoringConfig:
|
| 43 |
+
"""
|
| 44 |
+
Configuration for confidence and consistency scoring.
|
| 45 |
+
|
| 46 |
+
Used for evaluating LLM output quality:
|
| 47 |
+
- Range: [MIN, MAX]
|
| 48 |
+
- Default: Used as fallback when parsing fails
|
| 49 |
+
"""
|
| 50 |
+
MIN = 0
|
| 51 |
+
MAX = 100
|
| 52 |
+
DEFAULT = 50
|
| 53 |
+
|
| 54 |
+
# Quality thresholds for evaluation feedback
|
| 55 |
+
POOR_THRESHOLD = 39 # 0-39: poor quality, requires rework
|
| 56 |
+
PARTIAL_THRESHOLD = 69 # 40-69: partial quality, has gaps
|
| 57 |
+
GOOD_THRESHOLD = 89 # 70-89: good quality, manageable issues
|
| 58 |
+
# 90-100: excellent quality, production-ready
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SeverityConfig:
|
| 62 |
+
"""
|
| 63 |
+
Valid severity levels for mistakes/issues found in LLM output.
|
| 64 |
+
|
| 65 |
+
Used for categorizing problems:
|
| 66 |
+
- low: Minor issues, cosmetic
|
| 67 |
+
- medium: Moderate issues, affects usability
|
| 68 |
+
- high: Significant issues, affects correctness
|
| 69 |
+
- critical: Breaking issues, prevents function
|
| 70 |
+
"""
|
| 71 |
+
VALID_LEVELS = {"low", "medium", "high", "critical"}
|
| 72 |
+
DEFAULT = "high"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class CriticConfig:
|
| 76 |
+
"""
|
| 77 |
+
Configuration for critic/reviewer agents.
|
| 78 |
+
|
| 79 |
+
Used when evaluating LLM-generated code, research, or other output.
|
| 80 |
+
"""
|
| 81 |
+
# Default confidence score when parsing fails
|
| 82 |
+
PARSE_ERROR_CONFIDENCE = 25
|
| 83 |
+
PARSE_ERROR_CONSISTENCY = 25
|
| 84 |
+
|
| 85 |
+
# Default confidence score when parsing succeeds
|
| 86 |
+
SUCCESS_BASELINE_CONFIDENCE = 70
|
| 87 |
+
SUCCESS_BASELINE_CONSISTENCY = 70
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AgentConfig:
|
| 91 |
+
"""
|
| 92 |
+
Agent-specific configuration and parameters.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
class CodingAgent:
|
| 96 |
+
"""Code generation and review agent configuration."""
|
| 97 |
+
# Temperature for planning/routing decisions
|
| 98 |
+
PLANNER_TEMPERATURE = LLMConfig.PRECISE
|
| 99 |
+
|
| 100 |
+
# Temperature for parallel code generation
|
| 101 |
+
CODER_TEMPERATURE = LLMConfig.PRECISE
|
| 102 |
+
|
| 103 |
+
# Temperature for code merging/aggregation
|
| 104 |
+
AGGREGATOR_TEMPERATURE = LLMConfig.PRECISE
|
| 105 |
+
|
| 106 |
+
# Temperature for code review/criticism
|
| 107 |
+
REVIEWER_TEMPERATURE = LLMConfig.STRUCTURED
|
| 108 |
+
|
| 109 |
+
class OrchestratorAgent:
|
| 110 |
+
"""Deep research orchestrator agent configuration."""
|
| 111 |
+
# Temperature for research decomposition
|
| 112 |
+
DECOMPOSER_TEMPERATURE = LLMConfig.BALANCED
|
| 113 |
+
|
| 114 |
+
# Temperature for researcher agents
|
| 115 |
+
RESEARCHER_TEMPERATURE = LLMConfig.CREATIVE
|
| 116 |
+
|
| 117 |
+
# Temperature for research aggregation
|
| 118 |
+
AGGREGATOR_TEMPERATURE = LLMConfig.BALANCED
|
| 119 |
+
|
| 120 |
+
# Temperature for research quality criticism
|
| 121 |
+
CRITIC_TEMPERATURE = LLMConfig.STRUCTURED
|
| 122 |
+
|
| 123 |
+
class SmartOrchestrator:
|
| 124 |
+
"""Smart routing orchestrator configuration."""
|
| 125 |
+
# Temperature for query classification and routing
|
| 126 |
+
ROUTER_TEMPERATURE = LLMConfig.STRUCTURED
|
core/exceptions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class DatabaseError(Exception):
|
| 6 |
+
"""Raised when a database operation fails."""
|
| 7 |
+
|
| 8 |
+
def __init__(self, detail: str = "Database operation failed"):
|
| 9 |
+
self.detail = detail
|
| 10 |
+
super().__init__(self.detail)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LLMError(Exception):
|
| 14 |
+
"""Raised when an LLM call fails."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, detail: str = "LLM call failed"):
|
| 17 |
+
self.detail = detail
|
| 18 |
+
super().__init__(self.detail)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class QdrantError(Exception):
|
| 22 |
+
"""Raised when a Qdrant operation fails."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, detail: str = "Qdrant operation failed"):
|
| 25 |
+
self.detail = detail
|
| 26 |
+
super().__init__(self.detail)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def database_error_handler(request: Request, exc: DatabaseError):
|
| 30 |
+
"""Handle DatabaseError globally."""
|
| 31 |
+
return JSONResponse(status_code=500, content={"detail": f"Database error: {exc.detail}"})
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def llm_error_handler(request: Request, exc: LLMError):
|
| 35 |
+
"""Handle LLMError globally."""
|
| 36 |
+
return JSONResponse(status_code=502, content={"detail": f"LLM error: {exc.detail}"})
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def qdrant_error_handler(request: Request, exc: QdrantError):
|
| 40 |
+
"""Handle QdrantError globally."""
|
| 41 |
+
return JSONResponse(status_code=502, content={"detail": f"Qdrant error: {exc.detail}"})
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def generic_exception_handler(request: Request, exc: Exception):
|
| 45 |
+
"""Catch-all handler for unhandled exceptions."""
|
| 46 |
+
return JSONResponse(status_code=500, content={"detail": f"Internal server error: {str(exc)}"})
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def register_exception_handlers(app):
|
| 50 |
+
"""Register all custom exception handlers on a FastAPI app."""
|
| 51 |
+
app.add_exception_handler(DatabaseError, database_error_handler)
|
| 52 |
+
app.add_exception_handler(LLMError, llm_error_handler)
|
| 53 |
+
app.add_exception_handler(QdrantError, qdrant_error_handler)
|
| 54 |
+
app.add_exception_handler(Exception, generic_exception_handler)
|
core/llm_engine.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from groq import Groq
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from langchain_groq import ChatGroq
|
| 5 |
+
from langchain_openai import ChatOpenAI
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_llm(temperature: float = 0.1, change: bool = True, instant = False):
|
| 11 |
+
"""Return a ChatGroq LLM instance. change=True uses Llama-4, change=False uses GPT-oss."""
|
| 12 |
+
model = "openai/gpt-oss-20b"
|
| 13 |
+
if change:
|
| 14 |
+
model = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 15 |
+
if instant:
|
| 16 |
+
model = "llama-3.1-8b-instant"
|
| 17 |
+
|
| 18 |
+
return ChatGroq(
|
| 19 |
+
model=model,
|
| 20 |
+
api_key=os.getenv("GROQ_API_KEY"),
|
| 21 |
+
temperature=temperature,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_client() -> Groq:
|
| 26 |
+
"""Return a raw Groq client (for streaming completions)."""
|
| 27 |
+
return Groq(api_key=os.getenv("GROQ_API_KEY"))
|
| 28 |
+
|
| 29 |
+
def coding_llm(temperature: float = 0.2):
|
| 30 |
+
return ChatOpenAI(
|
| 31 |
+
model="stepfun/step-3.5-flash:free",
|
| 32 |
+
base_url="https://openrouter.ai/api/v1",
|
| 33 |
+
api_key=os.getenv("OPENROUTER_API_KEY"),
|
| 34 |
+
temperature=temperature
|
| 35 |
+
)
|
create_pdf_id_index.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
One-off script to create the missing pdf_id payload index on the pdf_chunks collection.
|
| 3 |
+
Run this once from the backend directory: python create_pdf_id_index.py
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from qdrant_client import QdrantClient
|
| 8 |
+
from qdrant_client.models import PayloadSchemaType
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
client = QdrantClient(
|
| 13 |
+
url=os.getenv("QDRANT_CLIENT"),
|
| 14 |
+
api_key=os.getenv("QDRANT_API_KEY"),
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
client.create_payload_index(
|
| 19 |
+
collection_name="pdf_chunks",
|
| 20 |
+
field_name="pdf_id",
|
| 21 |
+
field_schema=PayloadSchemaType.KEYWORD,
|
| 22 |
+
)
|
| 23 |
+
print("[OK] Created 'pdf_id' keyword index on 'pdf_chunks' collection.")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"[INFO] Index creation result: {e}")
|
| 26 |
+
print("(If this says 'already exists', the index was already created — you're good!)")
|
delete.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
delete.py — Drop all PostgreSQL tables and Qdrant collections.
|
| 3 |
+
Run this to reset the database before applying new schema changes.
|
| 4 |
+
Usage: python delete.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import asyncio
|
| 9 |
+
import asyncpg
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from qdrant_client import QdrantClient
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def drop_all_tables():
|
| 17 |
+
"""Drop all tables, enums, and indexes from PostgreSQL."""
|
| 18 |
+
conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
|
| 19 |
+
try:
|
| 20 |
+
# Drop tables (CASCADE handles FK dependencies)
|
| 21 |
+
await conn.execute("DROP TABLE IF EXISTS chunk_retrieval_log CASCADE;")
|
| 22 |
+
await conn.execute("DROP TABLE IF EXISTS detected_mistakes CASCADE;")
|
| 23 |
+
await conn.execute("DROP TABLE IF EXISTS debate_sessions CASCADE;")
|
| 24 |
+
await conn.execute("DROP TABLE IF EXISTS messages CASCADE;")
|
| 25 |
+
await conn.execute("DROP TABLE IF EXISTS conversations CASCADE;")
|
| 26 |
+
await conn.execute("DROP TABLE IF EXISTS users CASCADE;")
|
| 27 |
+
|
| 28 |
+
# Drop enum types
|
| 29 |
+
await conn.execute("DROP TYPE IF EXISTS conv_type CASCADE;")
|
| 30 |
+
await conn.execute("DROP TYPE IF EXISTS debate_winner CASCADE;")
|
| 31 |
+
await conn.execute("DROP TYPE IF EXISTS mistake_severity CASCADE;")
|
| 32 |
+
await conn.execute("DROP TYPE IF EXISTS reasoning_type CASCADE;")
|
| 33 |
+
|
| 34 |
+
print("[delete] All PostgreSQL tables and enums dropped.")
|
| 35 |
+
finally:
|
| 36 |
+
await conn.close()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def drop_qdrant_collections():
|
| 40 |
+
"""Delete Qdrant collections."""
|
| 41 |
+
client = QdrantClient(
|
| 42 |
+
url=os.getenv("QDRANT_CLIENT"),
|
| 43 |
+
api_key=os.getenv("QDRANT_API_KEY"),
|
| 44 |
+
)
|
| 45 |
+
collections = [c.name for c in client.get_collections().collections]
|
| 46 |
+
for name in collections:
|
| 47 |
+
client.delete_collection(name)
|
| 48 |
+
print(f"[delete] Deleted Qdrant collection: {name}")
|
| 49 |
+
|
| 50 |
+
if not collections:
|
| 51 |
+
print("[delete] No Qdrant collections found.")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def main():
|
| 55 |
+
print("=" * 50)
|
| 56 |
+
print(" Agentrix.io — Database Cleanup Script")
|
| 57 |
+
print("=" * 50)
|
| 58 |
+
|
| 59 |
+
await drop_all_tables()
|
| 60 |
+
drop_qdrant_collections()
|
| 61 |
+
|
| 62 |
+
print("\n[delete] Cleanup complete. Run `python main.py` to reinitialize.")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
asyncio.run(main())
|
repositories/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Repositories module — re-exports all database CRUD functions
|
| 2 |
+
from repositories.postgres_repo import (
|
| 3 |
+
init_db,
|
| 4 |
+
close_pool,
|
| 5 |
+
get_pool,
|
| 6 |
+
create_user,
|
| 7 |
+
get_user_by_email,
|
| 8 |
+
get_user_by_id,
|
| 9 |
+
create_conversation,
|
| 10 |
+
append_message,
|
| 11 |
+
create_debate_session,
|
| 12 |
+
get_debate_session_by_conversation_id,
|
| 13 |
+
get_user_history,
|
| 14 |
+
update_conversation_timestamp,
|
| 15 |
+
rename_conversation,
|
| 16 |
+
delete_conversation,
|
| 17 |
+
clear_all_history,
|
| 18 |
+
get_conversation_with_messages,
|
| 19 |
+
log_chunk_retrieval,
|
| 20 |
+
log_detected_mistakes,
|
| 21 |
+
get_pdf_quality_scores,
|
| 22 |
+
)
|
| 23 |
+
from repositories.qdrant_repo import (
|
| 24 |
+
get_embedding,
|
| 25 |
+
get_qdrant_client,
|
| 26 |
+
init_qdrant_collections,
|
| 27 |
+
upsert_pdf_summary,
|
| 28 |
+
get_user_pdf_summaries,
|
| 29 |
+
upsert_pdf_chunks,
|
| 30 |
+
search_chunks,
|
| 31 |
+
search_pdf_summary,
|
| 32 |
+
search_chunks_by_pdf_id,
|
| 33 |
+
get_pdf_ids_by_names,
|
| 34 |
+
get_most_recent_pdf_id,
|
| 35 |
+
)
|
repositories/postgres_repo.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import json as _json
|
| 5 |
+
|
| 6 |
+
import asyncpg
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from core.exceptions import DatabaseError
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
_pool: asyncpg.Pool | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def get_pool() -> asyncpg.Pool:
|
| 16 |
+
"""Returns the global asyncpg connection pool singleton."""
|
| 17 |
+
global _pool
|
| 18 |
+
if _pool is None:
|
| 19 |
+
_pool = await asyncpg.create_pool(
|
| 20 |
+
os.getenv("DATABASE_URL"),
|
| 21 |
+
min_size=2,
|
| 22 |
+
max_size=10,
|
| 23 |
+
)
|
| 24 |
+
return _pool
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def close_pool():
|
| 28 |
+
"""Close the global pool on shutdown."""
|
| 29 |
+
global _pool
|
| 30 |
+
if _pool:
|
| 31 |
+
await _pool.close()
|
| 32 |
+
_pool = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ─── Table Definitions ────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
CREATE_TABLES_SQL = """
|
| 38 |
+
-- Enum types
|
| 39 |
+
DO $$ BEGIN
|
| 40 |
+
CREATE TYPE conv_type AS ENUM ('standard', 'debate');
|
| 41 |
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
| 42 |
+
END $$;
|
| 43 |
+
|
| 44 |
+
DO $$ BEGIN
|
| 45 |
+
CREATE TYPE reasoning_type AS ENUM ('standard', 'deep_research', 'multi_agent');
|
| 46 |
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
| 47 |
+
END $$;
|
| 48 |
+
|
| 49 |
+
DO $$ BEGIN
|
| 50 |
+
CREATE TYPE mistake_severity AS ENUM ('low', 'medium', 'high');
|
| 51 |
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
| 52 |
+
END $$;
|
| 53 |
+
|
| 54 |
+
-- 1. Users table
|
| 55 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 56 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 57 |
+
email VARCHAR(255) UNIQUE NOT NULL,
|
| 58 |
+
password_hash VARCHAR(255) NOT NULL,
|
| 59 |
+
display_name VARCHAR(255),
|
| 60 |
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 61 |
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
-- 2. Conversations table
|
| 65 |
+
CREATE TABLE IF NOT EXISTS conversations (
|
| 66 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 67 |
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 68 |
+
type conv_type NOT NULL DEFAULT 'standard',
|
| 69 |
+
title VARCHAR(500),
|
| 70 |
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 71 |
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
-- 3. Messages table
|
| 75 |
+
-- Each row is one full query+response turn.
|
| 76 |
+
-- message JSONB stores an array: [{"user": "...", "assistant": "..."}, ...]
|
| 77 |
+
-- New messages are appended as new dicts in the array.
|
| 78 |
+
CREATE TABLE IF NOT EXISTS messages (
|
| 79 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 80 |
+
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
| 81 |
+
reasoning_mode reasoning_type NOT NULL,
|
| 82 |
+
message JSONB NOT NULL DEFAULT '[]',
|
| 83 |
+
confidence NUMERIC(4,3) CHECK (confidence BETWEEN 0 AND 1),
|
| 84 |
+
consistency NUMERIC(4,3) CHECK (consistency BETWEEN 0 AND 1),
|
| 85 |
+
pre_thinking JSONB DEFAULT NULL,
|
| 86 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
-- 4. Debate sessions table
|
| 90 |
+
CREATE TABLE IF NOT EXISTS debate_sessions (
|
| 91 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 92 |
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 93 |
+
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
| 94 |
+
topic TEXT NOT NULL,
|
| 95 |
+
debate_messages JSONB NOT NULL DEFAULT '[]',
|
| 96 |
+
verdict_text TEXT,
|
| 97 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
-- 5. Detected mistakes table
|
| 101 |
+
CREATE TABLE IF NOT EXISTS detected_mistakes (
|
| 102 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 103 |
+
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
|
| 104 |
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 105 |
+
description TEXT NOT NULL,
|
| 106 |
+
severity mistake_severity NOT NULL DEFAULT 'medium',
|
| 107 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 108 |
+
);
|
| 109 |
+
|
| 110 |
+
-- 6. Chunk retrieval log table
|
| 111 |
+
CREATE TABLE IF NOT EXISTS chunk_retrieval_log (
|
| 112 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 113 |
+
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
|
| 114 |
+
qdrant_chunk_id VARCHAR(255),
|
| 115 |
+
pdf_id VARCHAR(255),
|
| 116 |
+
similarity_score FLOAT,
|
| 117 |
+
quality_score FLOAT,
|
| 118 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
-- Indexes
|
| 122 |
+
CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON conversations(user_id);
|
| 123 |
+
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id_created ON messages(conversation_id, created_at);
|
| 124 |
+
CREATE INDEX IF NOT EXISTS idx_debate_sessions_user_id ON debate_sessions(user_id);
|
| 125 |
+
CREATE INDEX IF NOT EXISTS idx_debate_sessions_conversation_id ON debate_sessions(conversation_id);
|
| 126 |
+
CREATE INDEX IF NOT EXISTS idx_detected_mistakes_user_id ON detected_mistakes(user_id);
|
| 127 |
+
CREATE INDEX IF NOT EXISTS idx_chunk_retrieval_message_id ON chunk_retrieval_log(message_id);
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def init_db():
|
| 132 |
+
"""Initialize the database: create all tables and indexes."""
|
| 133 |
+
pool = await get_pool()
|
| 134 |
+
async with pool.acquire() as conn:
|
| 135 |
+
await conn.execute(CREATE_TABLES_SQL)
|
| 136 |
+
await _migrate_messages_pre_thinking(conn)
|
| 137 |
+
print("[postgres] Database initialized successfully.")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def _column_exists(conn: asyncpg.Connection, table: str, column: str) -> bool:
|
| 141 |
+
return bool(
|
| 142 |
+
await conn.fetchval(
|
| 143 |
+
"""
|
| 144 |
+
SELECT EXISTS (
|
| 145 |
+
SELECT 1
|
| 146 |
+
FROM information_schema.columns
|
| 147 |
+
WHERE table_schema = current_schema()
|
| 148 |
+
AND table_name = $1
|
| 149 |
+
AND column_name = $2
|
| 150 |
+
)
|
| 151 |
+
""",
|
| 152 |
+
table,
|
| 153 |
+
column,
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def _migrate_messages_pre_thinking(conn: asyncpg.Connection) -> None:
|
| 159 |
+
table_exists = bool(
|
| 160 |
+
await conn.fetchval(
|
| 161 |
+
"""
|
| 162 |
+
SELECT EXISTS (
|
| 163 |
+
SELECT 1
|
| 164 |
+
FROM information_schema.tables
|
| 165 |
+
WHERE table_schema = current_schema()
|
| 166 |
+
AND table_name = 'messages'
|
| 167 |
+
)
|
| 168 |
+
"""
|
| 169 |
+
)
|
| 170 |
+
)
|
| 171 |
+
if not table_exists:
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
has_pre_thinking = await _column_exists(conn, "messages", "pre_thinking")
|
| 175 |
+
has_what_happened = await _column_exists(conn, "messages", "what_happened")
|
| 176 |
+
|
| 177 |
+
if has_what_happened and not has_pre_thinking:
|
| 178 |
+
await conn.execute(
|
| 179 |
+
"ALTER TABLE messages RENAME COLUMN what_happened TO pre_thinking"
|
| 180 |
+
)
|
| 181 |
+
elif has_what_happened and has_pre_thinking:
|
| 182 |
+
await conn.execute(
|
| 183 |
+
"""
|
| 184 |
+
UPDATE messages
|
| 185 |
+
SET pre_thinking = COALESCE(pre_thinking, what_happened)
|
| 186 |
+
WHERE what_happened IS NOT NULL
|
| 187 |
+
"""
|
| 188 |
+
)
|
| 189 |
+
await conn.execute("ALTER TABLE messages DROP COLUMN what_happened")
|
| 190 |
+
elif not has_pre_thinking:
|
| 191 |
+
await conn.execute("ALTER TABLE messages ADD COLUMN pre_thinking JSONB DEFAULT NULL")
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ─── Helper Functions ─────────────────────────────────────────────────────────
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def create_user(
|
| 198 |
+
email: str, password_hash: str, display_name: str | None = None
|
| 199 |
+
) -> str:
|
| 200 |
+
"""Create a new user and return their UUID."""
|
| 201 |
+
pool = await get_pool()
|
| 202 |
+
async with pool.acquire() as conn:
|
| 203 |
+
row = await conn.fetchrow(
|
| 204 |
+
"INSERT INTO users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id",
|
| 205 |
+
email,
|
| 206 |
+
password_hash,
|
| 207 |
+
display_name,
|
| 208 |
+
)
|
| 209 |
+
return str(row["id"])
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def get_user_by_email(email: str) -> dict | None:
|
| 213 |
+
"""Fetch a user by email. Returns dict or None."""
|
| 214 |
+
pool = await get_pool()
|
| 215 |
+
async with pool.acquire() as conn:
|
| 216 |
+
row = await conn.fetchrow(
|
| 217 |
+
"SELECT id, email, password_hash, display_name FROM users WHERE email = $1",
|
| 218 |
+
email,
|
| 219 |
+
)
|
| 220 |
+
if row:
|
| 221 |
+
return dict(row)
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def get_user_by_id(user_id: str) -> dict | None:
|
| 226 |
+
"""Fetch a user by UUID. Returns dict or None."""
|
| 227 |
+
pool = await get_pool()
|
| 228 |
+
async with pool.acquire() as conn:
|
| 229 |
+
row = await conn.fetchrow(
|
| 230 |
+
"SELECT id, email, display_name FROM users WHERE id = $1",
|
| 231 |
+
uuid.UUID(user_id),
|
| 232 |
+
)
|
| 233 |
+
if row:
|
| 234 |
+
return dict(row)
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
async def create_conversation(
|
| 239 |
+
user_id: str, conv_type: str = "standard", title: str | None = None
|
| 240 |
+
) -> str:
|
| 241 |
+
"""Create a new conversation and return its UUID."""
|
| 242 |
+
pool = await get_pool()
|
| 243 |
+
async with pool.acquire() as conn:
|
| 244 |
+
row = await conn.fetchrow(
|
| 245 |
+
"INSERT INTO conversations (user_id, type, title) VALUES ($1, $2, $3) RETURNING id",
|
| 246 |
+
uuid.UUID(user_id),
|
| 247 |
+
conv_type,
|
| 248 |
+
title,
|
| 249 |
+
)
|
| 250 |
+
return str(row["id"])
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
async def append_message(
|
| 254 |
+
conversation_id: str,
|
| 255 |
+
reasoning_mode: str,
|
| 256 |
+
user_content: str,
|
| 257 |
+
assistant_content: str,
|
| 258 |
+
confidence: float | None = None,
|
| 259 |
+
consistency: float | None = None,
|
| 260 |
+
pdfs: list[str] | None = None,
|
| 261 |
+
pre_thinking: dict | None = None,
|
| 262 |
+
tools: list[str] | None = None,
|
| 263 |
+
) -> str:
|
| 264 |
+
"""
|
| 265 |
+
Append a user+assistant pair as a new row in the messages table.
|
| 266 |
+
Each row represents one full query+response turn.
|
| 267 |
+
Returns the new message row UUID.
|
| 268 |
+
"""
|
| 269 |
+
pool = await get_pool()
|
| 270 |
+
async with pool.acquire() as conn:
|
| 271 |
+
new_entry = {"user": user_content, "assistant": assistant_content}
|
| 272 |
+
if pdfs:
|
| 273 |
+
new_entry["pdfs"] = pdfs
|
| 274 |
+
if tools:
|
| 275 |
+
new_entry["tools"] = tools
|
| 276 |
+
row = await conn.fetchrow(
|
| 277 |
+
"INSERT INTO messages (conversation_id, reasoning_mode, message, confidence, consistency, pre_thinking) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
| 278 |
+
uuid.UUID(conversation_id),
|
| 279 |
+
reasoning_mode,
|
| 280 |
+
_json.dumps([new_entry]),
|
| 281 |
+
confidence,
|
| 282 |
+
consistency,
|
| 283 |
+
_json.dumps(pre_thinking) if pre_thinking else None,
|
| 284 |
+
)
|
| 285 |
+
return str(row["id"])
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
async def create_debate_session(
|
| 289 |
+
user_id: str,
|
| 290 |
+
conversation_id: str,
|
| 291 |
+
topic: str,
|
| 292 |
+
debate_messages: list[dict],
|
| 293 |
+
verdict_text: str | None = None,
|
| 294 |
+
) -> str:
|
| 295 |
+
"""Persist a debate session and return its UUID."""
|
| 296 |
+
pool = await get_pool()
|
| 297 |
+
async with pool.acquire() as conn:
|
| 298 |
+
row = await conn.fetchrow(
|
| 299 |
+
"INSERT INTO debate_sessions (user_id, conversation_id, topic, debate_messages, verdict_text) VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
| 300 |
+
uuid.UUID(user_id),
|
| 301 |
+
uuid.UUID(conversation_id),
|
| 302 |
+
topic,
|
| 303 |
+
_json.dumps(debate_messages),
|
| 304 |
+
verdict_text,
|
| 305 |
+
)
|
| 306 |
+
return str(row["id"])
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
async def get_debate_session_by_conversation_id(conversation_id: str, user_id: str) -> dict | None:
|
| 310 |
+
"""Fetch a debate session by its conversation UUID for the authenticated user."""
|
| 311 |
+
pool = await get_pool()
|
| 312 |
+
async with pool.acquire() as conn:
|
| 313 |
+
row = await conn.fetchrow(
|
| 314 |
+
"""
|
| 315 |
+
SELECT id, user_id, conversation_id, topic, debate_messages, verdict_text, created_at
|
| 316 |
+
FROM debate_sessions
|
| 317 |
+
WHERE conversation_id = $1 AND user_id = $2
|
| 318 |
+
""",
|
| 319 |
+
uuid.UUID(conversation_id),
|
| 320 |
+
uuid.UUID(user_id),
|
| 321 |
+
)
|
| 322 |
+
if not row:
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
data = dict(row)
|
| 326 |
+
if isinstance(data.get("debate_messages"), str):
|
| 327 |
+
data["debate_messages"] = _json.loads(data["debate_messages"])
|
| 328 |
+
return data
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
async def get_user_history(user_id: str) -> list[dict]:
|
| 332 |
+
"""Get conversation metadata for a user, ordered by most recent."""
|
| 333 |
+
pool = await get_pool()
|
| 334 |
+
async with pool.acquire() as conn:
|
| 335 |
+
rows = await conn.fetch(
|
| 336 |
+
"""
|
| 337 |
+
SELECT
|
| 338 |
+
c.id AS conv_id,
|
| 339 |
+
c.type AS conv_type,
|
| 340 |
+
c.title,
|
| 341 |
+
c.created_at AS conv_created,
|
| 342 |
+
c.updated_at AS conv_updated
|
| 343 |
+
FROM conversations c
|
| 344 |
+
WHERE c.user_id = $1
|
| 345 |
+
ORDER BY c.updated_at DESC
|
| 346 |
+
""",
|
| 347 |
+
uuid.UUID(user_id),
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
return [
|
| 351 |
+
{
|
| 352 |
+
"id": str(row["conv_id"]),
|
| 353 |
+
"type": row["conv_type"],
|
| 354 |
+
"title": row["title"],
|
| 355 |
+
"created_at": row["conv_created"].isoformat()
|
| 356 |
+
if row["conv_created"]
|
| 357 |
+
else None,
|
| 358 |
+
"updated_at": row["conv_updated"].isoformat()
|
| 359 |
+
if row["conv_updated"]
|
| 360 |
+
else None,
|
| 361 |
+
"messages": [],
|
| 362 |
+
}
|
| 363 |
+
for row in rows
|
| 364 |
+
]
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
async def update_conversation_timestamp(conversation_id: str):
|
| 368 |
+
"""Update the updated_at timestamp for a conversation."""
|
| 369 |
+
pool = await get_pool()
|
| 370 |
+
async with pool.acquire() as conn:
|
| 371 |
+
await conn.execute(
|
| 372 |
+
"UPDATE conversations SET updated_at = NOW() WHERE id = $1",
|
| 373 |
+
uuid.UUID(conversation_id),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
async def rename_conversation(
|
| 378 |
+
conversation_id: str, user_id: str, new_title: str
|
| 379 |
+
) -> bool:
|
| 380 |
+
"""Rename a conversation title. Returns True if updated."""
|
| 381 |
+
pool = await get_pool()
|
| 382 |
+
async with pool.acquire() as conn:
|
| 383 |
+
result = await conn.execute(
|
| 384 |
+
"UPDATE conversations SET title = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3",
|
| 385 |
+
new_title,
|
| 386 |
+
uuid.UUID(conversation_id),
|
| 387 |
+
uuid.UUID(user_id),
|
| 388 |
+
)
|
| 389 |
+
return result == "UPDATE 1"
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
async def delete_conversation(conversation_id: str, user_id: str) -> bool:
|
| 393 |
+
"""Delete a conversation and all its messages. Returns True if deleted."""
|
| 394 |
+
pool = await get_pool()
|
| 395 |
+
async with pool.acquire() as conn:
|
| 396 |
+
result = await conn.execute(
|
| 397 |
+
"DELETE FROM conversations WHERE id = $1 AND user_id = $2",
|
| 398 |
+
uuid.UUID(conversation_id),
|
| 399 |
+
uuid.UUID(user_id),
|
| 400 |
+
)
|
| 401 |
+
return result == "DELETE 1"
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
async def clear_all_history(user_id: str) -> int:
|
| 405 |
+
"""Delete all conversations for a user. Returns count deleted."""
|
| 406 |
+
pool = await get_pool()
|
| 407 |
+
async with pool.acquire() as conn:
|
| 408 |
+
result = await conn.execute(
|
| 409 |
+
"DELETE FROM conversations WHERE user_id = $1",
|
| 410 |
+
uuid.UUID(user_id),
|
| 411 |
+
)
|
| 412 |
+
return int(result.split()[-1]) if result.startswith("DELETE") else 0
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
async def log_chunk_retrieval(
|
| 416 |
+
message_id: str | None,
|
| 417 |
+
qdrant_chunk_id: str,
|
| 418 |
+
pdf_id: str,
|
| 419 |
+
similarity_score: float,
|
| 420 |
+
quality_score: float | None = None,
|
| 421 |
+
) -> str:
|
| 422 |
+
"""Log a chunk retrieval for analytics."""
|
| 423 |
+
pool = await get_pool()
|
| 424 |
+
async with pool.acquire() as conn:
|
| 425 |
+
row = await conn.fetchrow(
|
| 426 |
+
"INSERT INTO chunk_retrieval_log (message_id, qdrant_chunk_id, pdf_id, similarity_score, quality_score) VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
| 427 |
+
uuid.UUID(message_id) if message_id else None,
|
| 428 |
+
qdrant_chunk_id,
|
| 429 |
+
pdf_id,
|
| 430 |
+
similarity_score,
|
| 431 |
+
quality_score,
|
| 432 |
+
)
|
| 433 |
+
return str(row["id"])
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
async def get_pdf_quality_scores(pdf_ids: list[str]) -> dict[str, float]:
|
| 437 |
+
"""Get the average similarity score for a list of pdf_ids from chunk_retrieval_log."""
|
| 438 |
+
if not pdf_ids:
|
| 439 |
+
return {}
|
| 440 |
+
pool = await get_pool()
|
| 441 |
+
async with pool.acquire() as conn:
|
| 442 |
+
rows = await conn.fetch(
|
| 443 |
+
"SELECT pdf_id, AVG(similarity_score) as avg_score FROM chunk_retrieval_log WHERE pdf_id = ANY($1::text[]) GROUP BY pdf_id",
|
| 444 |
+
pdf_ids,
|
| 445 |
+
)
|
| 446 |
+
return {row["pdf_id"]: float(row["avg_score"]) for row in rows}
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
async def get_conversation_with_messages(conversation_id: str, user_id: str) -> dict:
|
| 450 |
+
"""Get a specific conversation and all its messages. Raises ValueError if not found."""
|
| 451 |
+
pool = await get_pool()
|
| 452 |
+
async with pool.acquire() as conn:
|
| 453 |
+
conv = await conn.fetchrow(
|
| 454 |
+
"SELECT id, type, title FROM conversations WHERE id = $1 AND user_id = $2",
|
| 455 |
+
uuid.UUID(conversation_id),
|
| 456 |
+
uuid.UUID(user_id),
|
| 457 |
+
)
|
| 458 |
+
if not conv:
|
| 459 |
+
raise ValueError("Conversation not found")
|
| 460 |
+
|
| 461 |
+
msgs = await conn.fetch(
|
| 462 |
+
"SELECT id, reasoning_mode, message, confidence, consistency, pre_thinking, created_at FROM messages WHERE conversation_id = $1 ORDER BY created_at ASC",
|
| 463 |
+
uuid.UUID(conversation_id),
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
messages = []
|
| 467 |
+
for m in msgs:
|
| 468 |
+
content = m["message"]
|
| 469 |
+
if isinstance(content, str):
|
| 470 |
+
content = _json.loads(content)
|
| 471 |
+
pre_thinking = m.get("pre_thinking")
|
| 472 |
+
if pre_thinking and not isinstance(pre_thinking, dict):
|
| 473 |
+
pre_thinking = _json.loads(pre_thinking) if pre_thinking else None
|
| 474 |
+
messages.append(
|
| 475 |
+
{
|
| 476 |
+
"id": str(m["id"]),
|
| 477 |
+
"reasoning_mode": m["reasoning_mode"],
|
| 478 |
+
"content": content,
|
| 479 |
+
"confidence": float(m["confidence"]) if m["confidence"] else None,
|
| 480 |
+
"consistency": float(m["consistency"])
|
| 481 |
+
if m["consistency"]
|
| 482 |
+
else None,
|
| 483 |
+
"pre_thinking": pre_thinking,
|
| 484 |
+
"created_at": m["created_at"].isoformat()
|
| 485 |
+
if m["created_at"]
|
| 486 |
+
else None,
|
| 487 |
+
}
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
return {
|
| 491 |
+
"conversation": {
|
| 492 |
+
"id": str(conv["id"]),
|
| 493 |
+
"type": conv["type"],
|
| 494 |
+
"title": conv["title"],
|
| 495 |
+
},
|
| 496 |
+
"messages": messages,
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
async def log_detected_mistakes(
|
| 501 |
+
message_id: str, user_id: str, mistakes: list[dict]
|
| 502 |
+
) -> None:
|
| 503 |
+
"""Log serious mistakes to the detected_mistakes table."""
|
| 504 |
+
if not mistakes:
|
| 505 |
+
return
|
| 506 |
+
pool = await get_pool()
|
| 507 |
+
async with pool.acquire() as conn:
|
| 508 |
+
values = []
|
| 509 |
+
for m in mistakes:
|
| 510 |
+
sev = str(m.get("severity", "medium")).lower()
|
| 511 |
+
if sev not in ("low", "medium", "high"):
|
| 512 |
+
sev = "medium"
|
| 513 |
+
desc = str(m.get("description", ""))
|
| 514 |
+
if desc:
|
| 515 |
+
values.append((uuid.UUID(message_id), uuid.UUID(user_id), desc, sev))
|
| 516 |
+
|
| 517 |
+
if values:
|
| 518 |
+
await conn.executemany(
|
| 519 |
+
"INSERT INTO detected_mistakes (message_id, user_id, description, severity) VALUES ($1, $2, $3, $4)",
|
| 520 |
+
values,
|
| 521 |
+
)
|
repositories/qdrant_repo.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from qdrant_client import QdrantClient
|
| 8 |
+
from qdrant_client.models import (
|
| 9 |
+
Distance,
|
| 10 |
+
FieldCondition,
|
| 11 |
+
Filter,
|
| 12 |
+
MatchValue,
|
| 13 |
+
PayloadSchemaType,
|
| 14 |
+
PointStruct,
|
| 15 |
+
VectorParams,
|
| 16 |
+
)
|
| 17 |
+
from sentence_transformers import SentenceTransformer
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
# ─── Embedding Model (cached) ─────────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
_embedding_model: SentenceTransformer | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_embedding_model() -> SentenceTransformer:
|
| 27 |
+
"""Get cached SentenceTransformer model."""
|
| 28 |
+
global _embedding_model
|
| 29 |
+
if _embedding_model is None:
|
| 30 |
+
_embedding_model = SentenceTransformer("BAAI/bge-small-en-v1.5")
|
| 31 |
+
return _embedding_model
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_embedding(text: str) -> list[float]:
|
| 35 |
+
"""Generate a 384-dim embedding for the given text."""
|
| 36 |
+
model = get_embedding_model()
|
| 37 |
+
embedding = model.encode(text, normalize_embeddings=True)
|
| 38 |
+
return embedding.tolist()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ─── Qdrant Client ────────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
_qdrant_client: QdrantClient | None = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_qdrant_client() -> QdrantClient:
|
| 47 |
+
"""Get the global Qdrant client singleton."""
|
| 48 |
+
global _qdrant_client
|
| 49 |
+
if _qdrant_client is None:
|
| 50 |
+
_qdrant_client = QdrantClient(
|
| 51 |
+
url=os.getenv("QDRANT_CLIENT"),
|
| 52 |
+
api_key=os.getenv("QDRANT_API_KEY"),
|
| 53 |
+
)
|
| 54 |
+
return _qdrant_client
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def init_qdrant_collections():
|
| 58 |
+
"""Create pdf_summary and pdf_chunks collections if they don't exist."""
|
| 59 |
+
client = get_qdrant_client()
|
| 60 |
+
collections = [c.name for c in client.get_collections().collections]
|
| 61 |
+
|
| 62 |
+
if "pdf_summary" not in collections:
|
| 63 |
+
client.create_collection(
|
| 64 |
+
collection_name="pdf_summary",
|
| 65 |
+
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
| 66 |
+
)
|
| 67 |
+
print("[qdrant] Created 'pdf_summary' collection.")
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
client.create_payload_index(
|
| 71 |
+
collection_name="pdf_summary",
|
| 72 |
+
field_name="user_id",
|
| 73 |
+
field_schema=PayloadSchemaType.KEYWORD,
|
| 74 |
+
)
|
| 75 |
+
print("[qdrant] Created 'user_id' index on 'pdf_summary'.")
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
if "pdf_chunks" not in collections:
|
| 80 |
+
client.create_collection(
|
| 81 |
+
collection_name="pdf_chunks",
|
| 82 |
+
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
| 83 |
+
)
|
| 84 |
+
print("[qdrant] Created 'pdf_chunks' collection.")
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
client.create_payload_index(
|
| 88 |
+
collection_name="pdf_chunks",
|
| 89 |
+
field_name="user_id",
|
| 90 |
+
field_schema=PayloadSchemaType.KEYWORD,
|
| 91 |
+
)
|
| 92 |
+
print("[qdrant] Created 'user_id' index on 'pdf_chunks'.")
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
# pdf_id index is required for filtering chunks by a specific PDF
|
| 97 |
+
try:
|
| 98 |
+
client.create_payload_index(
|
| 99 |
+
collection_name="pdf_chunks",
|
| 100 |
+
field_name="pdf_id",
|
| 101 |
+
field_schema=PayloadSchemaType.KEYWORD,
|
| 102 |
+
)
|
| 103 |
+
print("[qdrant] Created 'pdf_id' index on 'pdf_chunks'.")
|
| 104 |
+
except Exception:
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ─── PDF Summary CRUD ─────────────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
def upsert_pdf_summary(
|
| 111 |
+
pdf_id: str,
|
| 112 |
+
user_id: str,
|
| 113 |
+
conversation_id: str | None,
|
| 114 |
+
doc_name: str,
|
| 115 |
+
doc_summary: str,
|
| 116 |
+
topic_tags: list[str],
|
| 117 |
+
) -> str:
|
| 118 |
+
"""Store a PDF summary in the pdf_summary collection. Returns the point ID."""
|
| 119 |
+
client = get_qdrant_client()
|
| 120 |
+
embedding = get_embedding(doc_summary)
|
| 121 |
+
|
| 122 |
+
point_id = str(uuid.uuid4())
|
| 123 |
+
payload = {
|
| 124 |
+
"pdf_id": pdf_id,
|
| 125 |
+
"user_id": user_id,
|
| 126 |
+
"conversation_id": conversation_id,
|
| 127 |
+
"doc_name": doc_name,
|
| 128 |
+
"doc_summary": doc_summary,
|
| 129 |
+
"topic_tags": topic_tags,
|
| 130 |
+
"created_at": time.time(), # Unix timestamp — used to find the most recently uploaded PDF
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
client.upsert(
|
| 134 |
+
collection_name="pdf_summary",
|
| 135 |
+
points=[PointStruct(id=point_id, vector=embedding, payload=payload)],
|
| 136 |
+
)
|
| 137 |
+
return point_id
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def get_user_pdf_summaries(user_id: str) -> list[dict]:
|
| 141 |
+
"""Get all PDF summaries for a given user."""
|
| 142 |
+
client = get_qdrant_client()
|
| 143 |
+
|
| 144 |
+
results = client.scroll(
|
| 145 |
+
collection_name="pdf_summary",
|
| 146 |
+
scroll_filter=Filter(
|
| 147 |
+
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
| 148 |
+
),
|
| 149 |
+
limit=100,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
summaries = []
|
| 153 |
+
for point in results[0]:
|
| 154 |
+
summaries.append({
|
| 155 |
+
"id": str(point.id),
|
| 156 |
+
"pdf_id": point.payload.get("pdf_id"),
|
| 157 |
+
"doc_name": point.payload.get("doc_name"),
|
| 158 |
+
"doc_summary": point.payload.get("doc_summary"),
|
| 159 |
+
"topic_tags": point.payload.get("topic_tags", []),
|
| 160 |
+
"user_id": point.payload.get("user_id"),
|
| 161 |
+
"conversation_id": point.payload.get("conversation_id"),
|
| 162 |
+
})
|
| 163 |
+
return summaries
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ─── PDF Chunks CRUD ──────────────────────────────────────────────────────────
|
| 167 |
+
|
| 168 |
+
def upsert_pdf_chunks(
|
| 169 |
+
pdf_id: str,
|
| 170 |
+
user_id: str,
|
| 171 |
+
doc_name: str,
|
| 172 |
+
chunks: list[dict],
|
| 173 |
+
) -> int:
|
| 174 |
+
"""
|
| 175 |
+
Store PDF chunks in the pdf_chunks collection.
|
| 176 |
+
Each chunk dict should have: page_number, chunk_index, text_content.
|
| 177 |
+
Returns the number of chunks stored.
|
| 178 |
+
"""
|
| 179 |
+
client = get_qdrant_client()
|
| 180 |
+
|
| 181 |
+
points = []
|
| 182 |
+
for chunk in chunks:
|
| 183 |
+
embedding = get_embedding(chunk["text_content"])
|
| 184 |
+
point_id = str(uuid.uuid4())
|
| 185 |
+
payload = {
|
| 186 |
+
"pdf_id": pdf_id,
|
| 187 |
+
"user_id": user_id,
|
| 188 |
+
"doc_name": doc_name,
|
| 189 |
+
"page_number": chunk.get("page_number", 0),
|
| 190 |
+
"chunk_index": chunk.get("chunk_index", 0),
|
| 191 |
+
"text_content": chunk["text_content"],
|
| 192 |
+
}
|
| 193 |
+
points.append(PointStruct(id=point_id, vector=embedding, payload=payload))
|
| 194 |
+
|
| 195 |
+
batch_size = 100
|
| 196 |
+
for i in range(0, len(points), batch_size):
|
| 197 |
+
batch = points[i : i + batch_size]
|
| 198 |
+
client.upsert(collection_name="pdf_chunks", points=batch)
|
| 199 |
+
|
| 200 |
+
return len(points)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def search_chunks(query: str, user_id: str, top_k: int = 3) -> list[dict]:
|
| 204 |
+
"""Search for relevant chunks filtered by user_id."""
|
| 205 |
+
client = get_qdrant_client()
|
| 206 |
+
embedding = get_embedding(query)
|
| 207 |
+
|
| 208 |
+
results = client.query_points(
|
| 209 |
+
collection_name="pdf_chunks",
|
| 210 |
+
query=embedding,
|
| 211 |
+
query_filter=Filter(
|
| 212 |
+
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
| 213 |
+
),
|
| 214 |
+
limit=top_k,
|
| 215 |
+
with_payload=True,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
chunks = []
|
| 219 |
+
for hit in results.points:
|
| 220 |
+
chunks.append({
|
| 221 |
+
"id": str(hit.id),
|
| 222 |
+
"text_content": hit.payload.get("text_content", ""),
|
| 223 |
+
"pdf_id": hit.payload.get("pdf_id"),
|
| 224 |
+
"doc_name": hit.payload.get("doc_name"),
|
| 225 |
+
"page_number": hit.payload.get("page_number"),
|
| 226 |
+
"similarity_score": hit.score,
|
| 227 |
+
})
|
| 228 |
+
return chunks
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def search_pdf_summary(query: str, user_id: str, top_k: int = 3) -> list[dict]:
|
| 232 |
+
"""Search pdf_summary collection by query similarity for a given user.
|
| 233 |
+
Returns list of {pdf_id, doc_name, doc_summary, similarity_score}.
|
| 234 |
+
"""
|
| 235 |
+
client = get_qdrant_client()
|
| 236 |
+
embedding = get_embedding(query)
|
| 237 |
+
|
| 238 |
+
results = client.query_points(
|
| 239 |
+
collection_name="pdf_summary",
|
| 240 |
+
query=embedding,
|
| 241 |
+
query_filter=Filter(
|
| 242 |
+
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
| 243 |
+
),
|
| 244 |
+
limit=top_k,
|
| 245 |
+
with_payload=True,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
summaries = []
|
| 249 |
+
for hit in results.points:
|
| 250 |
+
summaries.append({
|
| 251 |
+
"id": str(hit.id),
|
| 252 |
+
"pdf_id": hit.payload.get("pdf_id"),
|
| 253 |
+
"doc_name": hit.payload.get("doc_name"),
|
| 254 |
+
"doc_summary": hit.payload.get("doc_summary"),
|
| 255 |
+
"topic_tags": hit.payload.get("topic_tags", []),
|
| 256 |
+
"similarity_score": hit.score,
|
| 257 |
+
})
|
| 258 |
+
return summaries
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def search_chunks_by_pdf_id(query: str, user_id: str, pdf_id: str, top_k: int = 5) -> list[dict]:
|
| 262 |
+
"""Search pdf_chunks filtered by a specific pdf_id. Returns the most relevant chunks."""
|
| 263 |
+
client = get_qdrant_client()
|
| 264 |
+
embedding = get_embedding(query)
|
| 265 |
+
|
| 266 |
+
results = client.query_points(
|
| 267 |
+
collection_name="pdf_chunks",
|
| 268 |
+
query=embedding,
|
| 269 |
+
query_filter=Filter(
|
| 270 |
+
must=[
|
| 271 |
+
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
| 272 |
+
FieldCondition(key="pdf_id", match=MatchValue(value=pdf_id)),
|
| 273 |
+
]
|
| 274 |
+
),
|
| 275 |
+
limit=top_k,
|
| 276 |
+
with_payload=True,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
chunks = []
|
| 280 |
+
for hit in results.points:
|
| 281 |
+
chunks.append({
|
| 282 |
+
"id": str(hit.id),
|
| 283 |
+
"text_content": hit.payload.get("text_content", ""),
|
| 284 |
+
"pdf_id": hit.payload.get("pdf_id"),
|
| 285 |
+
"doc_name": hit.payload.get("doc_name"),
|
| 286 |
+
"page_number": hit.payload.get("page_number"),
|
| 287 |
+
"similarity_score": hit.score,
|
| 288 |
+
})
|
| 289 |
+
return chunks
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def get_pdf_ids_by_names(doc_names: list[str], user_id: str) -> dict[str, str]:
|
| 293 |
+
"""Look up pdf_ids by doc_name for a given user. Returns {doc_name: pdf_id}."""
|
| 294 |
+
client = get_qdrant_client()
|
| 295 |
+
result: dict[str, str] = {}
|
| 296 |
+
|
| 297 |
+
hits, _ = client.scroll(
|
| 298 |
+
collection_name="pdf_summary",
|
| 299 |
+
scroll_filter=Filter(
|
| 300 |
+
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
| 301 |
+
),
|
| 302 |
+
limit=200,
|
| 303 |
+
with_payload=True,
|
| 304 |
+
)
|
| 305 |
+
for point in hits:
|
| 306 |
+
name = point.payload.get("doc_name", "")
|
| 307 |
+
if name in doc_names:
|
| 308 |
+
result[name] = point.payload.get("pdf_id", "")
|
| 309 |
+
|
| 310 |
+
return result
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def get_most_recent_pdf_id(user_id: str) -> str | None:
|
| 314 |
+
"""Return the pdf_id of the most recently uploaded PDF for a given user.
|
| 315 |
+
Uses the created_at timestamp stored in the pdf_summary payload.
|
| 316 |
+
Falls back gracefully for older entries that lack the timestamp.
|
| 317 |
+
"""
|
| 318 |
+
client = get_qdrant_client()
|
| 319 |
+
|
| 320 |
+
hits, _ = client.scroll(
|
| 321 |
+
collection_name="pdf_summary",
|
| 322 |
+
scroll_filter=Filter(
|
| 323 |
+
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
| 324 |
+
),
|
| 325 |
+
limit=200,
|
| 326 |
+
with_payload=True,
|
| 327 |
+
)
|
| 328 |
+
if not hits:
|
| 329 |
+
return None
|
| 330 |
+
|
| 331 |
+
# Sort descending by created_at; entries without the field get 0 (treated as oldest)
|
| 332 |
+
sorted_hits = sorted(hits, key=lambda p: p.payload.get("created_at", 0), reverse=True)
|
| 333 |
+
return sorted_hits[0].payload.get("pdf_id")
|
requirements.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
asyncpg
|
| 4 |
+
python-jose[cryptography]
|
| 5 |
+
bcrypt
|
| 6 |
+
python-multipart
|
| 7 |
+
qdrant-client
|
| 8 |
+
pymupdf
|
| 9 |
+
langchain-textsplitters
|
| 10 |
+
langchain-core
|
| 11 |
+
langchain-groq
|
| 12 |
+
langchain-openai
|
| 13 |
+
groq
|
| 14 |
+
python-dotenv
|
| 15 |
+
autogen-agentchat~=0.4.0.dev10
|
| 16 |
+
autogen-ext[openai]~=0.4.0.dev10
|
| 17 |
+
httpx
|
| 18 |
+
sentence-transformers
|
| 19 |
+
langgraph
|
| 20 |
+
# Install CPU-only torch to keep the image size manageable
|
| 21 |
+
torch --index-url https://download.pytorch.org/whl/cpu
|
routers/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Routers module — re-exports all API routers
|
| 2 |
+
from routers.auth_router import router as auth_router
|
| 3 |
+
from routers.chat_router import router as chat_router
|
| 4 |
+
from routers.history_router import router as history_router
|
| 5 |
+
from routers.pdf_router import router as pdf_router
|
| 6 |
+
from routers.debate_router import router as debate_router
|
| 7 |
+
from routers.admin_router import router as admin_router
|
| 8 |
+
from routers.reflection_router import router as reflection_router
|
routers/admin_router.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
from repositories import get_pool
|
| 3 |
+
|
| 4 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.get("/tables")
|
| 8 |
+
async def list_tables():
|
| 9 |
+
"""List all tables and their row counts."""
|
| 10 |
+
pool = await get_pool()
|
| 11 |
+
async with pool.acquire() as conn:
|
| 12 |
+
tables = [
|
| 13 |
+
"users",
|
| 14 |
+
"conversations",
|
| 15 |
+
"messages",
|
| 16 |
+
"debate_sessions",
|
| 17 |
+
"detected_mistakes",
|
| 18 |
+
"chunk_retrieval_log",
|
| 19 |
+
]
|
| 20 |
+
result = {}
|
| 21 |
+
for table in tables:
|
| 22 |
+
count = await conn.fetchval(f"SELECT COUNT(*) FROM {table}")
|
| 23 |
+
result[table] = count
|
| 24 |
+
return {"tables": result}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("/users")
|
| 28 |
+
async def view_users(limit: int = Query(50, le=200), offset: int = Query(0, ge=0)):
|
| 29 |
+
"""View all users (passwords masked)."""
|
| 30 |
+
pool = await get_pool()
|
| 31 |
+
async with pool.acquire() as conn:
|
| 32 |
+
rows = await conn.fetch(
|
| 33 |
+
"SELECT id, email, '***' as password_hash, display_name, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
| 34 |
+
limit, offset,
|
| 35 |
+
)
|
| 36 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM users")
|
| 37 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/conversations")
|
| 41 |
+
async def view_conversations(limit: int = Query(50, le=200), offset: int = Query(0, ge=0)):
|
| 42 |
+
"""View all conversations."""
|
| 43 |
+
pool = await get_pool()
|
| 44 |
+
async with pool.acquire() as conn:
|
| 45 |
+
rows = await conn.fetch(
|
| 46 |
+
"""SELECT c.id, c.user_id, u.email as user_email, c.type, c.title, c.created_at, c.updated_at
|
| 47 |
+
FROM conversations c LEFT JOIN users u ON c.user_id = u.id
|
| 48 |
+
ORDER BY c.updated_at DESC LIMIT $1 OFFSET $2""",
|
| 49 |
+
limit, offset,
|
| 50 |
+
)
|
| 51 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM conversations")
|
| 52 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/messages")
|
| 56 |
+
async def view_messages(
|
| 57 |
+
conversation_id: str | None = Query(None),
|
| 58 |
+
limit: int = Query(50, le=200),
|
| 59 |
+
offset: int = Query(0, ge=0),
|
| 60 |
+
):
|
| 61 |
+
"""View messages, optionally filtered by conversation_id."""
|
| 62 |
+
pool = await get_pool()
|
| 63 |
+
async with pool.acquire() as conn:
|
| 64 |
+
if conversation_id:
|
| 65 |
+
rows = await conn.fetch(
|
| 66 |
+
"""SELECT m.id, m.conversation_id, m.reasoning_mode, m.message, m.confidence, m.consistency, m.pre_thinking, m.created_at
|
| 67 |
+
FROM messages m WHERE m.conversation_id = $1
|
| 68 |
+
ORDER BY m.created_at ASC LIMIT $2 OFFSET $3""",
|
| 69 |
+
conversation_id, limit, offset,
|
| 70 |
+
)
|
| 71 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM messages WHERE conversation_id = $1", conversation_id)
|
| 72 |
+
else:
|
| 73 |
+
rows = await conn.fetch(
|
| 74 |
+
"""SELECT m.id, m.conversation_id, m.reasoning_mode, m.message, m.confidence, m.consistency, m.pre_thinking, m.created_at
|
| 75 |
+
FROM messages m ORDER BY m.created_at DESC LIMIT $1 OFFSET $2""",
|
| 76 |
+
limit, offset,
|
| 77 |
+
)
|
| 78 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM messages")
|
| 79 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/messages/{message_id}")
|
| 83 |
+
async def view_message_detail(message_id: str):
|
| 84 |
+
"""View full details of a single message including JSONB content."""
|
| 85 |
+
import json as _json
|
| 86 |
+
pool = await get_pool()
|
| 87 |
+
async with pool.acquire() as conn:
|
| 88 |
+
row = await conn.fetchrow(
|
| 89 |
+
"SELECT * FROM messages WHERE id = $1", message_id,
|
| 90 |
+
)
|
| 91 |
+
if not row:
|
| 92 |
+
raise HTTPException(status_code=404, detail="Message not found")
|
| 93 |
+
data = dict(row)
|
| 94 |
+
# Parse JSONB message content
|
| 95 |
+
if isinstance(data.get("message"), str):
|
| 96 |
+
data["message"] = _json.loads(data["message"])
|
| 97 |
+
return data
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/debate-sessions")
|
| 101 |
+
async def view_debate_sessions(limit: int = Query(50, le=200), offset: int = Query(0, ge=0)):
|
| 102 |
+
"""View all debate sessions."""
|
| 103 |
+
pool = await get_pool()
|
| 104 |
+
async with pool.acquire() as conn:
|
| 105 |
+
rows = await conn.fetch(
|
| 106 |
+
"""SELECT d.id, d.user_id, u.email as user_email, d.conversation_id, d.topic,
|
| 107 |
+
LENGTH(d.debate_messages::text) as messages_size,
|
| 108 |
+
LENGTH(d.verdict_text) as verdict_size, d.created_at
|
| 109 |
+
FROM debate_sessions d LEFT JOIN users u ON d.user_id = u.id
|
| 110 |
+
ORDER BY d.created_at DESC LIMIT $1 OFFSET $2""",
|
| 111 |
+
limit, offset,
|
| 112 |
+
)
|
| 113 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM debate_sessions")
|
| 114 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.get("/debate-sessions/{session_id}")
|
| 118 |
+
async def view_debate_session_detail(session_id: str):
|
| 119 |
+
"""View full details of a single debate session."""
|
| 120 |
+
import json as _json
|
| 121 |
+
pool = await get_pool()
|
| 122 |
+
async with pool.acquire() as conn:
|
| 123 |
+
row = await conn.fetchrow("SELECT * FROM debate_sessions WHERE id = $1", session_id)
|
| 124 |
+
if not row:
|
| 125 |
+
raise HTTPException(status_code=404, detail="Debate session not found")
|
| 126 |
+
data = dict(row)
|
| 127 |
+
if isinstance(data.get("debate_messages"), str):
|
| 128 |
+
data["debate_messages"] = _json.loads(data["debate_messages"])
|
| 129 |
+
return data
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.get("/mistakes")
|
| 133 |
+
async def view_mistakes(limit: int = Query(50, le=200), offset: int = Query(0, ge=0)):
|
| 134 |
+
"""View all detected mistakes."""
|
| 135 |
+
pool = await get_pool()
|
| 136 |
+
async with pool.acquire() as conn:
|
| 137 |
+
rows = await conn.fetch(
|
| 138 |
+
"""SELECT dm.id, dm.message_id, dm.user_id, u.email as user_email,
|
| 139 |
+
dm.description, dm.severity, dm.created_at
|
| 140 |
+
FROM detected_mistakes dm LEFT JOIN users u ON dm.user_id = u.id
|
| 141 |
+
ORDER BY dm.created_at DESC LIMIT $1 OFFSET $2""",
|
| 142 |
+
limit, offset,
|
| 143 |
+
)
|
| 144 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM detected_mistakes")
|
| 145 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.get("/chunk-logs")
|
| 149 |
+
async def view_chunk_logs(limit: int = Query(50, le=200), offset: int = Query(0, ge=0)):
|
| 150 |
+
"""View chunk retrieval logs."""
|
| 151 |
+
pool = await get_pool()
|
| 152 |
+
async with pool.acquire() as conn:
|
| 153 |
+
rows = await conn.fetch(
|
| 154 |
+
"""SELECT id, message_id, qdrant_chunk_id, pdf_id, similarity_score, quality_score, created_at
|
| 155 |
+
FROM chunk_retrieval_log ORDER BY created_at DESC LIMIT $1 OFFSET $2""",
|
| 156 |
+
limit, offset,
|
| 157 |
+
)
|
| 158 |
+
total = await conn.fetchval("SELECT COUNT(*) FROM chunk_retrieval_log")
|
| 159 |
+
return {"total": total, "offset": offset, "limit": limit, "data": [dict(r) for r in rows]}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.get("/stats")
|
| 163 |
+
async def view_stats():
|
| 164 |
+
"""Get database statistics."""
|
| 165 |
+
pool = await get_pool()
|
| 166 |
+
async with pool.acquire() as conn:
|
| 167 |
+
stats = {}
|
| 168 |
+
stats["users"] = await conn.fetchval("SELECT COUNT(*) FROM users")
|
| 169 |
+
stats["conversations"] = await conn.fetchval("SELECT COUNT(*) FROM conversations")
|
| 170 |
+
stats["conversations_standard"] = await conn.fetchval("SELECT COUNT(*) FROM conversations WHERE type = 'standard'")
|
| 171 |
+
stats["conversations_debate"] = await conn.fetchval("SELECT COUNT(*) FROM conversations WHERE type = 'debate'")
|
| 172 |
+
stats["messages"] = await conn.fetchval("SELECT COUNT(*) FROM messages")
|
| 173 |
+
stats["debate_sessions"] = await conn.fetchval("SELECT COUNT(*) FROM debate_sessions")
|
| 174 |
+
stats["detected_mistakes"] = await conn.fetchval("SELECT COUNT(*) FROM detected_mistakes")
|
| 175 |
+
stats["chunk_retrieval_logs"] = await conn.fetchval("SELECT COUNT(*) FROM chunk_retrieval_log")
|
| 176 |
+
|
| 177 |
+
# Recent activity
|
| 178 |
+
recent_conv = await conn.fetchrow("SELECT created_at FROM conversations ORDER BY created_at DESC LIMIT 1")
|
| 179 |
+
stats["last_conversation_at"] = str(recent_conv["created_at"]) if recent_conv else None
|
| 180 |
+
|
| 181 |
+
return stats
|
routers/auth_router.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from core import hash_password, verify_password, create_token
|
| 3 |
+
from repositories import create_user, get_user_by_email
|
| 4 |
+
from schemas import RegisterRequest, LoginRequest
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.post("/register")
|
| 10 |
+
async def register(req: RegisterRequest):
|
| 11 |
+
"""Register a new user and return a JWT token."""
|
| 12 |
+
try:
|
| 13 |
+
existing = await get_user_by_email(req.email)
|
| 14 |
+
if existing:
|
| 15 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 16 |
+
|
| 17 |
+
password_hash = hash_password(req.password)
|
| 18 |
+
user_id = await create_user(req.email, password_hash, req.display_name)
|
| 19 |
+
token = create_token(user_id)
|
| 20 |
+
|
| 21 |
+
return {
|
| 22 |
+
"token": token,
|
| 23 |
+
"user_id": user_id,
|
| 24 |
+
"display_name": req.display_name,
|
| 25 |
+
}
|
| 26 |
+
except HTTPException:
|
| 27 |
+
raise
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/login")
|
| 33 |
+
async def login(req: LoginRequest):
|
| 34 |
+
"""Login with email and password, return a JWT token."""
|
| 35 |
+
try:
|
| 36 |
+
user = await get_user_by_email(req.email)
|
| 37 |
+
if not user:
|
| 38 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 39 |
+
|
| 40 |
+
if not verify_password(req.password, user["password_hash"]):
|
| 41 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 42 |
+
|
| 43 |
+
token = create_token(str(user["id"]))
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"token": token,
|
| 47 |
+
"user_id": str(user["id"]),
|
| 48 |
+
"display_name": user.get("display_name"),
|
| 49 |
+
}
|
| 50 |
+
except HTTPException:
|
| 51 |
+
raise
|
| 52 |
+
except Exception as e:
|
| 53 |
+
raise HTTPException(status_code=500, detail=str(e))
|
routers/chat_router.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
+
from core import get_current_user
|
| 6 |
+
|
| 7 |
+
from schemas import (
|
| 8 |
+
QueryRequest,
|
| 9 |
+
TaskRequest,
|
| 10 |
+
SmartOrchestratorRequest
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from services import (
|
| 14 |
+
run_tool_agent_stream_sse,
|
| 15 |
+
smart_orchestrator_stream,
|
| 16 |
+
get_conversation_memory_context_async,
|
| 17 |
+
run_deep_research_stream_with_state,
|
| 18 |
+
_to_non_empty_text,
|
| 19 |
+
add_to_memory
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
from repositories import (
|
| 23 |
+
create_conversation,
|
| 24 |
+
append_message,
|
| 25 |
+
update_conversation_timestamp,
|
| 26 |
+
log_chunk_retrieval,
|
| 27 |
+
log_detected_mistakes,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
router = APIRouter(tags=["chat"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/chat/stream")
|
| 36 |
+
async def agent_query_stream(
|
| 37 |
+
req: QueryRequest, user_id: str = Depends(get_current_user)
|
| 38 |
+
):
|
| 39 |
+
"""
|
| 40 |
+
Standard chat endpoint with SSE streaming (3-phase: initial → tools → final).
|
| 41 |
+
|
| 42 |
+
Flow:
|
| 43 |
+
1. Load memory context (if conversation_id provided)
|
| 44 |
+
2. Stream tool agent execution via SSE
|
| 45 |
+
3. Persist message + chunk logs after streaming completes
|
| 46 |
+
"""
|
| 47 |
+
logger.info(
|
| 48 |
+
f"[chat_router:stream] Request from user={user_id}, conv_id={req.conversation_id}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# ── Fetch prior conversation memory ─
|
| 52 |
+
memory_context: str | None = None
|
| 53 |
+
if req.conversation_id:
|
| 54 |
+
logger.info(
|
| 55 |
+
f"[chat_router:stream] Fetching memory for conv_id={req.conversation_id}"
|
| 56 |
+
)
|
| 57 |
+
memory_context = await get_conversation_memory_context_async(
|
| 58 |
+
req.conversation_id, user_id
|
| 59 |
+
)
|
| 60 |
+
if memory_context:
|
| 61 |
+
logger.info(
|
| 62 |
+
f"[chat_router:stream] Memory context ready: {len(memory_context)} chars"
|
| 63 |
+
)
|
| 64 |
+
else:
|
| 65 |
+
logger.info(
|
| 66 |
+
f"[chat_router:stream] No prior memory found for conv_id={req.conversation_id}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
async def event_generator():
|
| 70 |
+
final_answer = ""
|
| 71 |
+
retrieved_chunks = []
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
# Stream the tool agent execution
|
| 75 |
+
async for sse_line in run_tool_agent_stream_sse(
|
| 76 |
+
query=req.query,
|
| 77 |
+
user_id=user_id,
|
| 78 |
+
pdfs=req.pdfs,
|
| 79 |
+
memory_context=memory_context,
|
| 80 |
+
):
|
| 81 |
+
# Parse to capture final data for DB persistence
|
| 82 |
+
try:
|
| 83 |
+
if sse_line.startswith("data: "):
|
| 84 |
+
evt = json.loads(sse_line[6:].strip())
|
| 85 |
+
if evt.get("type") == "done":
|
| 86 |
+
final_answer = evt.get("answer", "")
|
| 87 |
+
tools_used = evt.get("tools_used", [])
|
| 88 |
+
retrieved_chunks = evt.get("retrieved_chunks", [])
|
| 89 |
+
elif evt.get("type") == "error":
|
| 90 |
+
logger.error(
|
| 91 |
+
f"[chat_router:stream] Stream error: {evt.get('message')}"
|
| 92 |
+
)
|
| 93 |
+
except (json.JSONDecodeError, IndexError):
|
| 94 |
+
pass
|
| 95 |
+
yield sse_line
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"[chat_router:stream] Streaming error: {e}", exc_info=True)
|
| 99 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 100 |
+
|
| 101 |
+
# ── Persist to DB after streaming ─
|
| 102 |
+
try:
|
| 103 |
+
conv_id = req.conversation_id
|
| 104 |
+
if not conv_id:
|
| 105 |
+
conv_id = await create_conversation(
|
| 106 |
+
user_id, "standard", req.query[:200]
|
| 107 |
+
)
|
| 108 |
+
logger.info(f"[chat_router:stream] Created new conversation: {conv_id}")
|
| 109 |
+
|
| 110 |
+
message_id = await append_message(
|
| 111 |
+
conversation_id=conv_id,
|
| 112 |
+
reasoning_mode="standard",
|
| 113 |
+
user_content=req.query,
|
| 114 |
+
assistant_content=final_answer,
|
| 115 |
+
pdfs=req.pdfs,
|
| 116 |
+
)
|
| 117 |
+
await update_conversation_timestamp(conv_id)
|
| 118 |
+
logger.info(
|
| 119 |
+
f"[chat_router:stream] Persisted message {message_id} to conv {conv_id}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Log retrieved chunks
|
| 123 |
+
for chunk in retrieved_chunks:
|
| 124 |
+
try:
|
| 125 |
+
await log_chunk_retrieval(
|
| 126 |
+
message_id=message_id,
|
| 127 |
+
qdrant_chunk_id=chunk.get("id", ""),
|
| 128 |
+
pdf_id=chunk.get("pdf_id", ""),
|
| 129 |
+
similarity_score=chunk.get("similarity_score", 0.0),
|
| 130 |
+
quality_score=None,
|
| 131 |
+
)
|
| 132 |
+
except Exception as log_err:
|
| 133 |
+
logger.warning(
|
| 134 |
+
f"[chat_router:stream] Chunk log failed for {chunk.get('id')}: {log_err}"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Emit conversation_id to frontend
|
| 138 |
+
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id})}\n\n"
|
| 139 |
+
|
| 140 |
+
# ── Update window memory ─────────────────────────────
|
| 141 |
+
if conv_id:
|
| 142 |
+
add_to_memory(conv_id, req.query, final_answer)
|
| 143 |
+
|
| 144 |
+
except Exception as db_err:
|
| 145 |
+
logger.error(
|
| 146 |
+
f"[chat_router:stream] DB persist error: {db_err}", exc_info=True
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
yield 'data: {"type": "done"}\n\n'
|
| 150 |
+
|
| 151 |
+
return StreamingResponse(
|
| 152 |
+
event_generator(),
|
| 153 |
+
media_type="text/event-stream",
|
| 154 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.post("/deep_research/task")
|
| 159 |
+
async def deep_research_task(req: TaskRequest, user_id: str = Depends(get_current_user)):
|
| 160 |
+
"""Run the multi-agent orchestrator (deep research) with SSE streaming."""
|
| 161 |
+
|
| 162 |
+
async def event_generator():
|
| 163 |
+
final_result = ""
|
| 164 |
+
final_meta = {}
|
| 165 |
+
conv_id = req.conversation_id
|
| 166 |
+
pre_thinking = {"decomposition": "", "researcher1": "", "researcher2": ""}
|
| 167 |
+
aggregation_content = ""
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
# Load memory context
|
| 171 |
+
memory_context: str | None = None
|
| 172 |
+
if req.conversation_id:
|
| 173 |
+
memory_context = await get_conversation_memory_context_async(req.conversation_id, user_id)
|
| 174 |
+
|
| 175 |
+
# Stream orchestrator events
|
| 176 |
+
async for event in run_deep_research_stream_with_state(req.task, memory_context=memory_context):
|
| 177 |
+
event_type = event.get("type", "")
|
| 178 |
+
|
| 179 |
+
# Forward all events to frontend
|
| 180 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 181 |
+
|
| 182 |
+
# Capture final result for DB persistence
|
| 183 |
+
if event_type == "final":
|
| 184 |
+
final_result = event.get("result", "")
|
| 185 |
+
final_meta = event.get("meta", {})
|
| 186 |
+
elif event_type == "content_chunk":
|
| 187 |
+
section = event.get("section", "")
|
| 188 |
+
content = event.get("content", "")
|
| 189 |
+
if section == "decomposition":
|
| 190 |
+
pre_thinking["decomposition"] = content
|
| 191 |
+
elif section == "researcher_1":
|
| 192 |
+
pre_thinking["researcher1"] = content
|
| 193 |
+
elif section == "researcher_2":
|
| 194 |
+
pre_thinking["researcher2"] = content
|
| 195 |
+
elif section == "aggregation":
|
| 196 |
+
aggregation_content = content
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"[deep_research_router] Streaming error: {e}", exc_info=True)
|
| 200 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 201 |
+
|
| 202 |
+
# Persist to DB after streaming completes
|
| 203 |
+
try:
|
| 204 |
+
if not conv_id:
|
| 205 |
+
conv_id = await create_conversation(user_id, "standard", req.task[:200])
|
| 206 |
+
|
| 207 |
+
raw_conf = final_meta.get("confidence_score")
|
| 208 |
+
raw_cons = final_meta.get("logical_consistency")
|
| 209 |
+
deep_research_raw = final_meta.get("deep_research_raw", {})
|
| 210 |
+
|
| 211 |
+
assistant_content_to_store = (
|
| 212 |
+
_to_non_empty_text(final_result)
|
| 213 |
+
or (
|
| 214 |
+
_to_non_empty_text(deep_research_raw.get("final_result", ""))
|
| 215 |
+
if isinstance(deep_research_raw, dict)
|
| 216 |
+
else ""
|
| 217 |
+
)
|
| 218 |
+
or _to_non_empty_text(aggregation_content)
|
| 219 |
+
or "Deep research completed."
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Only save pre_thinking if we have decomposition content
|
| 223 |
+
pre_thinking_data = (
|
| 224 |
+
pre_thinking if pre_thinking.get("decomposition") else None
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
await append_message(
|
| 228 |
+
conversation_id=conv_id,
|
| 229 |
+
reasoning_mode="deep_research",
|
| 230 |
+
user_content=req.task,
|
| 231 |
+
assistant_content=assistant_content_to_store,
|
| 232 |
+
confidence=raw_conf / 100 if raw_conf is not None else None,
|
| 233 |
+
consistency=raw_cons / 100 if raw_cons is not None else None,
|
| 234 |
+
pre_thinking=pre_thinking_data,
|
| 235 |
+
tools=["Researcher_Agent1", "Researcher_Agent2", "Aggregator_Agent"],
|
| 236 |
+
)
|
| 237 |
+
await update_conversation_timestamp(conv_id)
|
| 238 |
+
|
| 239 |
+
# Emit conversation_id to frontend
|
| 240 |
+
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id})}\n\n"
|
| 241 |
+
|
| 242 |
+
# Update window memory
|
| 243 |
+
if conv_id:
|
| 244 |
+
add_to_memory(conv_id, req.task, assistant_content_to_store)
|
| 245 |
+
|
| 246 |
+
except Exception as db_err:
|
| 247 |
+
logger.error(f"[deep_research_router] DB persist error: {db_err}", exc_info=True)
|
| 248 |
+
|
| 249 |
+
yield 'data: {"type": "done"}\n\n'
|
| 250 |
+
|
| 251 |
+
return StreamingResponse(
|
| 252 |
+
event_generator(),
|
| 253 |
+
media_type="text/event-stream",
|
| 254 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@router.post("/smart-orchestrator/stream")
|
| 259 |
+
async def smart_orchestrator_endpoint(
|
| 260 |
+
req: SmartOrchestratorRequest, user_id: str = Depends(get_current_user)
|
| 261 |
+
):
|
| 262 |
+
"""Smart orchestrator SSE stream (routes to standard/deep_research/code)."""
|
| 263 |
+
final_result = ""
|
| 264 |
+
meta_info = {}
|
| 265 |
+
detected_path = "standard"
|
| 266 |
+
pre_thinking = {"decomposition": "", "researcher1": "", "researcher2": ""}
|
| 267 |
+
deep_research_aggregation = ""
|
| 268 |
+
code_pre_thinking = {
|
| 269 |
+
"problem_understanding": "",
|
| 270 |
+
"approach": "",
|
| 271 |
+
"agent_outputs": [],
|
| 272 |
+
"file_outputs": [],
|
| 273 |
+
}
|
| 274 |
+
code_final_marker = None
|
| 275 |
+
|
| 276 |
+
def _to_non_empty_text(value) -> str:
|
| 277 |
+
if value is None:
|
| 278 |
+
return ""
|
| 279 |
+
if isinstance(value, str):
|
| 280 |
+
return value.strip()
|
| 281 |
+
if isinstance(value, (dict, list)):
|
| 282 |
+
try:
|
| 283 |
+
return json.dumps(value, ensure_ascii=False).strip()
|
| 284 |
+
except Exception:
|
| 285 |
+
return str(value).strip()
|
| 286 |
+
return str(value).strip()
|
| 287 |
+
|
| 288 |
+
def _extract_code_complete_marker(value):
|
| 289 |
+
parsed_result = None
|
| 290 |
+
if isinstance(value, str) and value.strip():
|
| 291 |
+
try:
|
| 292 |
+
parsed_result = json.loads(value)
|
| 293 |
+
except json.JSONDecodeError:
|
| 294 |
+
parsed_result = None
|
| 295 |
+
elif isinstance(value, dict):
|
| 296 |
+
parsed_result = value
|
| 297 |
+
|
| 298 |
+
if isinstance(parsed_result, dict) and parsed_result.get("type") == "code_complete":
|
| 299 |
+
return parsed_result
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
def _build_code_approach_from_subtasks(subtasks) -> str:
|
| 303 |
+
if not isinstance(subtasks, list):
|
| 304 |
+
return ""
|
| 305 |
+
lines = []
|
| 306 |
+
for subtask in subtasks:
|
| 307 |
+
if not isinstance(subtask, dict):
|
| 308 |
+
continue
|
| 309 |
+
desc = str(subtask.get("description", "")).strip()
|
| 310 |
+
if not desc:
|
| 311 |
+
continue
|
| 312 |
+
subtask_id = subtask.get("id")
|
| 313 |
+
if subtask_id is not None:
|
| 314 |
+
lines.append(f"- Agent {subtask_id}: {desc}")
|
| 315 |
+
else:
|
| 316 |
+
lines.append(f"- {desc}")
|
| 317 |
+
if not lines:
|
| 318 |
+
return ""
|
| 319 |
+
return "Parallel implementation plan:\n" + "\n".join(lines)
|
| 320 |
+
|
| 321 |
+
def _build_code_assistant_content(
|
| 322 |
+
problem_understanding: str,
|
| 323 |
+
approach: str,
|
| 324 |
+
final_marker: dict | None,
|
| 325 |
+
) -> str:
|
| 326 |
+
sections = []
|
| 327 |
+
if problem_understanding:
|
| 328 |
+
sections.append(f"Problem Understanding:\n{problem_understanding}")
|
| 329 |
+
if approach:
|
| 330 |
+
sections.append(f"Approach:\n{approach}")
|
| 331 |
+
summary = "\n\n".join(sections).strip()
|
| 332 |
+
if summary:
|
| 333 |
+
return summary
|
| 334 |
+
|
| 335 |
+
if isinstance(final_marker, dict):
|
| 336 |
+
file_count = final_marker.get("file_count")
|
| 337 |
+
filenames = final_marker.get("filenames", [])
|
| 338 |
+
if isinstance(file_count, int):
|
| 339 |
+
if isinstance(filenames, list) and filenames:
|
| 340 |
+
return (
|
| 341 |
+
f"Code generation completed with {file_count} file(s): "
|
| 342 |
+
+ ", ".join(str(name) for name in filenames)
|
| 343 |
+
)
|
| 344 |
+
return f"Code generation completed with {file_count} file(s)."
|
| 345 |
+
return ""
|
| 346 |
+
|
| 347 |
+
async def event_generator():
|
| 348 |
+
nonlocal final_result, meta_info, detected_path, pre_thinking, deep_research_aggregation, code_pre_thinking, code_final_marker
|
| 349 |
+
try:
|
| 350 |
+
async for chunk in smart_orchestrator_stream(
|
| 351 |
+
req.task,
|
| 352 |
+
conversation_id=req.conversation_id,
|
| 353 |
+
user_id=user_id,
|
| 354 |
+
):
|
| 355 |
+
try:
|
| 356 |
+
if chunk.startswith("data: "):
|
| 357 |
+
evt = json.loads(chunk[6:].strip())
|
| 358 |
+
evt_type = evt.get("type")
|
| 359 |
+
if evt_type == "final":
|
| 360 |
+
final_result = evt.get("result", "")
|
| 361 |
+
meta_info = evt.get("meta", {})
|
| 362 |
+
if detected_path == "code":
|
| 363 |
+
parsed_result = _extract_code_complete_marker(final_result)
|
| 364 |
+
if isinstance(parsed_result, dict):
|
| 365 |
+
code_final_marker = parsed_result
|
| 366 |
+
elif evt_type == "route":
|
| 367 |
+
detected_path = evt.get("path", "standard")
|
| 368 |
+
elif evt_type == "content_chunk":
|
| 369 |
+
section = evt.get("section", "")
|
| 370 |
+
content = evt.get("content", "")
|
| 371 |
+
if section == "decomposition":
|
| 372 |
+
pre_thinking["decomposition"] = content
|
| 373 |
+
elif section == "researcher_1":
|
| 374 |
+
pre_thinking["researcher1"] = content
|
| 375 |
+
elif section == "researcher_2":
|
| 376 |
+
pre_thinking["researcher2"] = content
|
| 377 |
+
elif section == "aggregation":
|
| 378 |
+
deep_research_aggregation = content
|
| 379 |
+
elif evt_type == "code_section":
|
| 380 |
+
section = evt.get("section", "")
|
| 381 |
+
content = evt.get("content", "")
|
| 382 |
+
if section == "problem_understanding":
|
| 383 |
+
code_pre_thinking["problem_understanding"] = content
|
| 384 |
+
elif section == "approach":
|
| 385 |
+
code_pre_thinking["approach"] = content
|
| 386 |
+
elif evt_type == "plan" and detected_path == "code":
|
| 387 |
+
if not code_pre_thinking.get("approach"):
|
| 388 |
+
generated_approach = _build_code_approach_from_subtasks(
|
| 389 |
+
evt.get("subtasks", [])
|
| 390 |
+
)
|
| 391 |
+
if generated_approach:
|
| 392 |
+
code_pre_thinking["approach"] = generated_approach
|
| 393 |
+
elif evt_type == "agent_output" and detected_path == "code":
|
| 394 |
+
code_pre_thinking["agent_outputs"].append(
|
| 395 |
+
{
|
| 396 |
+
"agent_id": evt.get("agent_id"),
|
| 397 |
+
"agent_name": evt.get("agent_name"),
|
| 398 |
+
"content": evt.get("content", ""),
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
elif evt_type == "file_output" and detected_path == "code":
|
| 402 |
+
code_pre_thinking["file_outputs"].append(
|
| 403 |
+
{
|
| 404 |
+
"filename": evt.get("filename", ""),
|
| 405 |
+
"language": evt.get("language", "text"),
|
| 406 |
+
"index": evt.get("index"),
|
| 407 |
+
"total": evt.get("total"),
|
| 408 |
+
"content": evt.get("content", ""),
|
| 409 |
+
}
|
| 410 |
+
)
|
| 411 |
+
except (json.JSONDecodeError, IndexError):
|
| 412 |
+
pass
|
| 413 |
+
yield chunk
|
| 414 |
+
except Exception as e:
|
| 415 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 416 |
+
finally:
|
| 417 |
+
try:
|
| 418 |
+
reasoning = (
|
| 419 |
+
"multi_agent"
|
| 420 |
+
if detected_path in ("deep_research", "code")
|
| 421 |
+
else "multi_agent"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
conv_id = req.conversation_id
|
| 425 |
+
if not conv_id:
|
| 426 |
+
conv_id = await create_conversation(
|
| 427 |
+
user_id, "standard", req.task[:200]
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
raw_conf = meta_info.get("confidence_score")
|
| 431 |
+
raw_cons = meta_info.get("logical_consistency")
|
| 432 |
+
serious_mistakes = meta_info.get("serious_mistakes", [])
|
| 433 |
+
orchestrator_raw = meta_info.get("orchestrator_raw", {})
|
| 434 |
+
orchestrator_raw_result = (
|
| 435 |
+
_to_non_empty_text(orchestrator_raw.get("final_result", ""))
|
| 436 |
+
if isinstance(orchestrator_raw, dict)
|
| 437 |
+
else ""
|
| 438 |
+
)
|
| 439 |
+
path_specific_final_result = _to_non_empty_text(final_result)
|
| 440 |
+
deep_research_chunk_content = _to_non_empty_text(deep_research_aggregation)
|
| 441 |
+
code_summary_content = ""
|
| 442 |
+
pre_thinking_data = None
|
| 443 |
+
|
| 444 |
+
if detected_path == "deep_research":
|
| 445 |
+
pre_thinking_data = (
|
| 446 |
+
pre_thinking if pre_thinking.get("decomposition") else None
|
| 447 |
+
)
|
| 448 |
+
elif detected_path == "code":
|
| 449 |
+
parsed_result = _extract_code_complete_marker(final_result)
|
| 450 |
+
if code_final_marker is None and isinstance(parsed_result, dict):
|
| 451 |
+
code_final_marker = parsed_result
|
| 452 |
+
if isinstance(parsed_result, dict):
|
| 453 |
+
path_specific_final_result = ""
|
| 454 |
+
|
| 455 |
+
problem_understanding = str(
|
| 456 |
+
code_pre_thinking.get("problem_understanding", "")
|
| 457 |
+
).strip()
|
| 458 |
+
approach = str(code_pre_thinking.get("approach", "")).strip()
|
| 459 |
+
agent_outputs = code_pre_thinking.get("agent_outputs", [])
|
| 460 |
+
file_outputs = code_pre_thinking.get("file_outputs", [])
|
| 461 |
+
|
| 462 |
+
code_summary_content = _build_code_assistant_content(
|
| 463 |
+
problem_understanding,
|
| 464 |
+
approach,
|
| 465 |
+
code_final_marker
|
| 466 |
+
if isinstance(code_final_marker, dict)
|
| 467 |
+
else None,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
pre_thinking_data = {
|
| 471 |
+
"route_path": detected_path,
|
| 472 |
+
"agent_outputs": agent_outputs,
|
| 473 |
+
"file_outputs": file_outputs,
|
| 474 |
+
}
|
| 475 |
+
if isinstance(code_final_marker, dict):
|
| 476 |
+
pre_thinking_data["final_marker"] = code_final_marker
|
| 477 |
+
|
| 478 |
+
if not (
|
| 479 |
+
pre_thinking_data["agent_outputs"]
|
| 480 |
+
or pre_thinking_data["file_outputs"]
|
| 481 |
+
or pre_thinking_data.get("final_marker")
|
| 482 |
+
):
|
| 483 |
+
pre_thinking_data = None
|
| 484 |
+
|
| 485 |
+
# Build tools list based on detected path
|
| 486 |
+
tools = None
|
| 487 |
+
if detected_path == "deep_research":
|
| 488 |
+
tools = ["Researcher_Agent1", "Researcher_Agent2", "Aggregator_Agent"]
|
| 489 |
+
elif detected_path == "code":
|
| 490 |
+
tools = ["Code_Planner", "Coder_1", "Coder_2", "Coder_3", "Aggregator", "Reviewer"]
|
| 491 |
+
else:
|
| 492 |
+
# Standard mode - use tools from meta
|
| 493 |
+
tools = meta_info.get("tools_used", [])
|
| 494 |
+
if tools:
|
| 495 |
+
tools = [t if isinstance(t, str) else t.get("tool", str(t)) for t in tools]
|
| 496 |
+
|
| 497 |
+
explicit_fallback_text = (
|
| 498 |
+
"Deep research completed."
|
| 499 |
+
if detected_path == "deep_research"
|
| 500 |
+
else "Code generation completed."
|
| 501 |
+
if detected_path == "code"
|
| 502 |
+
else "Request completed."
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
assistant_content_to_store = (
|
| 506 |
+
path_specific_final_result
|
| 507 |
+
or orchestrator_raw_result
|
| 508 |
+
or deep_research_chunk_content
|
| 509 |
+
or code_summary_content
|
| 510 |
+
or explicit_fallback_text
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
message_id = await append_message(
|
| 514 |
+
conversation_id=conv_id,
|
| 515 |
+
reasoning_mode=reasoning,
|
| 516 |
+
user_content=req.task,
|
| 517 |
+
assistant_content=assistant_content_to_store,
|
| 518 |
+
confidence=raw_conf / 100 if raw_conf is not None else None,
|
| 519 |
+
consistency=raw_cons / 100 if raw_cons is not None else None,
|
| 520 |
+
pdfs=req.pdfs,
|
| 521 |
+
pre_thinking=pre_thinking_data,
|
| 522 |
+
tools=tools,
|
| 523 |
+
)
|
| 524 |
+
await update_conversation_timestamp(conv_id)
|
| 525 |
+
|
| 526 |
+
if serious_mistakes:
|
| 527 |
+
try:
|
| 528 |
+
await log_detected_mistakes(
|
| 529 |
+
message_id, user_id, serious_mistakes
|
| 530 |
+
)
|
| 531 |
+
except Exception as log_err:
|
| 532 |
+
print(f"[chat_router] Mistake log error: {log_err}")
|
| 533 |
+
|
| 534 |
+
# Emit the final conversation_id to the frontend
|
| 535 |
+
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id})}\n\n"
|
| 536 |
+
|
| 537 |
+
# ── Update window memory ─────────────────────────────
|
| 538 |
+
if conv_id:
|
| 539 |
+
add_to_memory(conv_id, req.task, assistant_content_to_store)
|
| 540 |
+
|
| 541 |
+
except Exception as db_err:
|
| 542 |
+
print(f"[chat_router] DB persist error: {db_err}")
|
| 543 |
+
|
| 544 |
+
yield 'data: {"type": "done"}\n\n'
|
| 545 |
+
|
| 546 |
+
return StreamingResponse(
|
| 547 |
+
event_generator(),
|
| 548 |
+
media_type="text/event-stream",
|
| 549 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 550 |
+
)
|
routers/debate_router.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from fastapi.responses import StreamingResponse
|
| 4 |
+
from core import get_current_user
|
| 5 |
+
|
| 6 |
+
from services import (
|
| 7 |
+
run_debate_stream,
|
| 8 |
+
structure_debate_rounds
|
| 9 |
+
)
|
| 10 |
+
from repositories import (
|
| 11 |
+
create_conversation,
|
| 12 |
+
create_debate_session,
|
| 13 |
+
get_debate_session_by_conversation_id,
|
| 14 |
+
update_conversation_timestamp
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(tags=["debate"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/debate/stream")
|
| 21 |
+
async def debate_stream(
|
| 22 |
+
topic: str,
|
| 23 |
+
rounds: int = 3,
|
| 24 |
+
mode: str = "autogen",
|
| 25 |
+
user_id: str = Depends(get_current_user)
|
| 26 |
+
):
|
| 27 |
+
"""Stream a debate between two agents via SSE."""
|
| 28 |
+
debate_events = []
|
| 29 |
+
|
| 30 |
+
async def event_generator():
|
| 31 |
+
nonlocal debate_events
|
| 32 |
+
try:
|
| 33 |
+
async for msg in run_debate_stream(topic, rounds, mode):
|
| 34 |
+
debate_events.append(msg)
|
| 35 |
+
yield f"data: {json.dumps(msg)}\n\n"
|
| 36 |
+
|
| 37 |
+
# Persist debate session after all rounds complete
|
| 38 |
+
conv_id = await create_conversation(user_id, "debate", topic[:200])
|
| 39 |
+
|
| 40 |
+
structured_rounds = structure_debate_rounds(debate_events)
|
| 41 |
+
|
| 42 |
+
verdict_text = None
|
| 43 |
+
for msg in debate_events:
|
| 44 |
+
if msg.get("type") == "verdict":
|
| 45 |
+
verdict_text = msg.get("content", "")
|
| 46 |
+
|
| 47 |
+
await create_debate_session(
|
| 48 |
+
user_id=user_id,
|
| 49 |
+
conversation_id=conv_id,
|
| 50 |
+
topic=topic,
|
| 51 |
+
debate_messages=structured_rounds,
|
| 52 |
+
verdict_text=verdict_text,
|
| 53 |
+
)
|
| 54 |
+
await update_conversation_timestamp(conv_id)
|
| 55 |
+
|
| 56 |
+
# Emit conversation_id for this debate session
|
| 57 |
+
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id})}\n\n"
|
| 58 |
+
except ValueError as ve:
|
| 59 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(ve)})}\n\n"
|
| 60 |
+
except Exception as db_err:
|
| 61 |
+
print(f"[debate_router] DB persist error: {db_err}")
|
| 62 |
+
|
| 63 |
+
yield "data: {\"type\": \"done\"}\n\n"
|
| 64 |
+
|
| 65 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/debate/session/{conversation_id}")
|
| 69 |
+
async def get_debate_session(conversation_id: str, user_id: str = Depends(get_current_user)):
|
| 70 |
+
"""Return a saved debate session for history replay."""
|
| 71 |
+
session = await get_debate_session_by_conversation_id(conversation_id, user_id)
|
| 72 |
+
if not session:
|
| 73 |
+
return {"session": None}
|
| 74 |
+
return {"session": session}
|
routers/history_router.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from core import get_current_user
|
| 4 |
+
from repositories import (
|
| 5 |
+
get_user_history,
|
| 6 |
+
rename_conversation,
|
| 7 |
+
delete_conversation,
|
| 8 |
+
clear_all_history,
|
| 9 |
+
get_conversation_with_messages,
|
| 10 |
+
)
|
| 11 |
+
from services import (
|
| 12 |
+
clear_conversation_memory,
|
| 13 |
+
get_all_conversations
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
from schemas import (
|
| 17 |
+
RenameRequest
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(tags=["history"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/history")
|
| 24 |
+
async def get_history(user_id: str = Depends(get_current_user)):
|
| 25 |
+
"""Get all conversations with messages for the authenticated user."""
|
| 26 |
+
try:
|
| 27 |
+
history = await get_user_history(user_id)
|
| 28 |
+
return {"conversations": history}
|
| 29 |
+
except Exception as e:
|
| 30 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.put("/history/{conversation_id}")
|
| 34 |
+
async def rename_conv(
|
| 35 |
+
conversation_id: str, req: RenameRequest, user_id: str = Depends(get_current_user)
|
| 36 |
+
):
|
| 37 |
+
"""Rename a conversation title."""
|
| 38 |
+
try:
|
| 39 |
+
ok = await rename_conversation(conversation_id, user_id, req.title)
|
| 40 |
+
if not ok:
|
| 41 |
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
| 42 |
+
return {"status": "ok"}
|
| 43 |
+
except HTTPException:
|
| 44 |
+
raise
|
| 45 |
+
except Exception as e:
|
| 46 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.delete("/history/{conversation_id}")
|
| 50 |
+
async def delete_conv(conversation_id: str, user_id: str = Depends(get_current_user)):
|
| 51 |
+
"""Delete a conversation and all its messages."""
|
| 52 |
+
try:
|
| 53 |
+
ok = await delete_conversation(conversation_id, user_id)
|
| 54 |
+
if not ok:
|
| 55 |
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
| 56 |
+
# Clear from window memory
|
| 57 |
+
clear_conversation_memory(conversation_id)
|
| 58 |
+
return {"status": "ok"}
|
| 59 |
+
except HTTPException:
|
| 60 |
+
raise
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.delete("/history")
|
| 66 |
+
async def clear_history(user_id: str = Depends(get_current_user)):
|
| 67 |
+
"""Clear all conversation history for the user."""
|
| 68 |
+
try:
|
| 69 |
+
count = await clear_all_history(user_id)
|
| 70 |
+
# Clear all window memories for this user (clear all since all convs deleted)
|
| 71 |
+
all_convs = get_all_conversations()
|
| 72 |
+
for conv_id in all_convs:
|
| 73 |
+
clear_conversation_memory(conv_id)
|
| 74 |
+
return {"status": "ok", "deleted": count}
|
| 75 |
+
except Exception as e:
|
| 76 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/history/{conversation_id}")
|
| 80 |
+
async def get_conversation_messages(
|
| 81 |
+
conversation_id: str, user_id: str = Depends(get_current_user)
|
| 82 |
+
):
|
| 83 |
+
"""Get all messages for a specific conversation."""
|
| 84 |
+
try:
|
| 85 |
+
result = await get_conversation_with_messages(conversation_id, user_id)
|
| 86 |
+
return result
|
| 87 |
+
except ValueError:
|
| 88 |
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
raise HTTPException(status_code=500, detail=str(e))
|
routers/pdf_router.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import tempfile
|
| 4 |
+
from fastapi import APIRouter, UploadFile, File, Depends
|
| 5 |
+
from core import get_current_user
|
| 6 |
+
from utils.pdf_processor import process_pdfs
|
| 7 |
+
from repositories import (
|
| 8 |
+
create_conversation,
|
| 9 |
+
update_conversation_timestamp,
|
| 10 |
+
get_user_pdf_summaries,
|
| 11 |
+
get_pdf_quality_scores
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
router = APIRouter(tags=["pdf"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/upload-pdf")
|
| 18 |
+
async def upload_pdfs(
|
| 19 |
+
files: list[UploadFile] = File(...),
|
| 20 |
+
conversation_id: str | None = None,
|
| 21 |
+
user_id: str = Depends(get_current_user),
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Accepts multiple PDF files, saves them temporarily,
|
| 25 |
+
processes them through pdf_processor, and stores in Qdrant.
|
| 26 |
+
"""
|
| 27 |
+
temp_dir = tempfile.mkdtemp()
|
| 28 |
+
file_paths = []
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
for file in files:
|
| 32 |
+
if not file.filename:
|
| 33 |
+
continue
|
| 34 |
+
temp_path = os.path.join(temp_dir, file.filename)
|
| 35 |
+
with open(temp_path, "wb") as buffer:
|
| 36 |
+
content = await file.read()
|
| 37 |
+
buffer.write(content)
|
| 38 |
+
file_paths.append(temp_path)
|
| 39 |
+
|
| 40 |
+
conv_id = conversation_id
|
| 41 |
+
if not conv_id:
|
| 42 |
+
conv_id = await create_conversation(user_id, "standard", f"PDF Upload: {len(files)} file(s)")
|
| 43 |
+
|
| 44 |
+
results = await process_pdfs(file_paths, user_id, conversation_id=conv_id)
|
| 45 |
+
await update_conversation_timestamp(conv_id)
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
"status": "success",
|
| 49 |
+
"processed": results.get("total_chunks", 0),
|
| 50 |
+
"details": results,
|
| 51 |
+
"conversation_id": conv_id,
|
| 52 |
+
}
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"[pdf_router] PDF processing error: {e}")
|
| 55 |
+
return {
|
| 56 |
+
"status": "partial",
|
| 57 |
+
"processed": 0,
|
| 58 |
+
"details": {"error": str(e), "note": "Qdrant may be unavailable. PDFs processed but not stored."},
|
| 59 |
+
}
|
| 60 |
+
finally:
|
| 61 |
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/memory/pdfs")
|
| 65 |
+
async def get_memory_pdfs(user_id: str = Depends(get_current_user)):
|
| 66 |
+
"""Get all PDF summaries for the authenticated user."""
|
| 67 |
+
try:
|
| 68 |
+
summaries = get_user_pdf_summaries(user_id)
|
| 69 |
+
|
| 70 |
+
if summaries:
|
| 71 |
+
pdf_ids = [s.get("pdf_id") for s in summaries if s.get("pdf_id")]
|
| 72 |
+
if pdf_ids:
|
| 73 |
+
scores = await get_pdf_quality_scores(pdf_ids)
|
| 74 |
+
for s in summaries:
|
| 75 |
+
pid = s.get("pdf_id")
|
| 76 |
+
if pid and pid in scores:
|
| 77 |
+
# Normalize to a percentage if it's cosine similarity (-1 to 1 or 0 to 1)
|
| 78 |
+
# We'll just pass it as is or multiply by 100 on the frontend, let's keep it 0-100 format if it was 0-1
|
| 79 |
+
# If average similarity is 0.85, maybe we want it as 85.
|
| 80 |
+
avg = scores[pid]
|
| 81 |
+
s["quality_score"] = int(avg * 100) if 0 <= avg <= 1 else int(avg)
|
| 82 |
+
|
| 83 |
+
return {"pdfs": summaries}
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"[pdf_router] Qdrant unavailable, returning empty list: {e}")
|
| 86 |
+
return {"pdfs": []}
|
routers/reflection_router.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
|
| 7 |
+
from core import get_current_user
|
| 8 |
+
from repositories import get_pool
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/reflection", tags=["reflection"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _clamp_score(value: float, minimum: int = 0, maximum: int = 100) -> int:
|
| 14 |
+
return max(minimum, min(maximum, round(value)))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _normalize_to_percent(value: float | None) -> float | None:
|
| 18 |
+
if value is None:
|
| 19 |
+
return None
|
| 20 |
+
return value * 100 if value <= 1 else value
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _default_improvement(severity: str) -> str:
|
| 24 |
+
if severity == "high":
|
| 25 |
+
return "Escalated to stricter validation and cross-checking gates."
|
| 26 |
+
if severity == "medium":
|
| 27 |
+
return "Added additional reasoning checks before final response."
|
| 28 |
+
return "Applied lightweight post-response self-review for similar prompts."
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _default_strategy(severity: str) -> str:
|
| 32 |
+
if severity == "high":
|
| 33 |
+
return "Require tool-backed evidence and second-pass verification for claims."
|
| 34 |
+
if severity == "medium":
|
| 35 |
+
return "Increase consistency checks on multi-step reasoning chains."
|
| 36 |
+
return "Track pattern frequency and auto-flag repeated low-severity slips."
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _build_radar(
|
| 40 |
+
confidence_score: int,
|
| 41 |
+
logical_consistency: int,
|
| 42 |
+
factual_reliability: int,
|
| 43 |
+
self_correction_triggered: bool,
|
| 44 |
+
issue_count: int,
|
| 45 |
+
high_severity_count: int,
|
| 46 |
+
) -> list[dict]:
|
| 47 |
+
adaptation = _clamp_score(
|
| 48 |
+
72
|
| 49 |
+
+ (14 if self_correction_triggered else 4)
|
| 50 |
+
- (high_severity_count * 4)
|
| 51 |
+
- min(issue_count, 6)
|
| 52 |
+
)
|
| 53 |
+
return [
|
| 54 |
+
{"metric": "Planning", "value": logical_consistency},
|
| 55 |
+
{"metric": "Reasoning", "value": confidence_score},
|
| 56 |
+
{"metric": "Verification", "value": factual_reliability},
|
| 57 |
+
{"metric": "Adaptation", "value": adaptation},
|
| 58 |
+
{"metric": "Confidence", "value": confidence_score},
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("")
|
| 63 |
+
@router.get("/summary")
|
| 64 |
+
async def get_reflection_summary(user_id: str = Depends(get_current_user)):
|
| 65 |
+
"""
|
| 66 |
+
User-scoped reflection summary:
|
| 67 |
+
- confidence / logical consistency from messages table (all user conversations)
|
| 68 |
+
- reflection report issues from detected_mistakes
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
pool = await get_pool()
|
| 72 |
+
async with pool.acquire() as conn:
|
| 73 |
+
quality_row = await conn.fetchrow(
|
| 74 |
+
"""
|
| 75 |
+
SELECT
|
| 76 |
+
AVG(m.confidence) AS avg_confidence,
|
| 77 |
+
AVG(m.consistency) AS avg_consistency,
|
| 78 |
+
COUNT(m.id) AS message_count
|
| 79 |
+
FROM messages m
|
| 80 |
+
JOIN conversations c ON c.id = m.conversation_id
|
| 81 |
+
WHERE c.user_id = $1
|
| 82 |
+
""",
|
| 83 |
+
uuid.UUID(user_id),
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
mistakes = await conn.fetch(
|
| 87 |
+
"""
|
| 88 |
+
SELECT id, description, severity
|
| 89 |
+
FROM detected_mistakes
|
| 90 |
+
WHERE user_id = $1
|
| 91 |
+
ORDER BY created_at DESC
|
| 92 |
+
LIMIT 200
|
| 93 |
+
""",
|
| 94 |
+
uuid.UUID(user_id),
|
| 95 |
+
)
|
| 96 |
+
except Exception as exc:
|
| 97 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
| 98 |
+
|
| 99 |
+
avg_confidence = _normalize_to_percent(
|
| 100 |
+
float(quality_row["avg_confidence"])
|
| 101 |
+
if quality_row and quality_row["avg_confidence"] is not None
|
| 102 |
+
else None
|
| 103 |
+
)
|
| 104 |
+
avg_consistency = _normalize_to_percent(
|
| 105 |
+
float(quality_row["avg_consistency"])
|
| 106 |
+
if quality_row and quality_row["avg_consistency"] is not None
|
| 107 |
+
else None
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
confidence_score = _clamp_score(avg_confidence if avg_confidence is not None else 82)
|
| 111 |
+
logical_consistency = _clamp_score(avg_consistency if avg_consistency is not None else 84)
|
| 112 |
+
|
| 113 |
+
severity_count = {"high": 0, "medium": 0, "low": 0}
|
| 114 |
+
issues = []
|
| 115 |
+
for idx, row in enumerate(mistakes):
|
| 116 |
+
severity = str(row["severity"] or "medium").lower()
|
| 117 |
+
if severity not in severity_count:
|
| 118 |
+
severity = "medium"
|
| 119 |
+
severity_count[severity] += 1
|
| 120 |
+
issues.append(
|
| 121 |
+
{
|
| 122 |
+
"id": str(row["id"]),
|
| 123 |
+
"issue": row["description"] or "Detected reasoning issue.",
|
| 124 |
+
"improvement": _default_improvement(severity),
|
| 125 |
+
"strategy": _default_strategy(severity),
|
| 126 |
+
"severity": severity,
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
message_count = int(quality_row["message_count"]) if quality_row and quality_row["message_count"] else 0
|
| 131 |
+
issue_penalty = (
|
| 132 |
+
severity_count["high"] * 12
|
| 133 |
+
+ severity_count["medium"] * 7
|
| 134 |
+
+ severity_count["low"] * 4
|
| 135 |
+
)
|
| 136 |
+
density_penalty = (
|
| 137 |
+
min(15, round((len(issues) / message_count) * 30)) if message_count > 0 else 0
|
| 138 |
+
)
|
| 139 |
+
factual_reliability = _clamp_score(92 - issue_penalty - density_penalty, 35, 98)
|
| 140 |
+
self_correction_triggered = len(issues) > 0
|
| 141 |
+
|
| 142 |
+
return {
|
| 143 |
+
"scores": {
|
| 144 |
+
"confidenceScore": confidence_score,
|
| 145 |
+
"logicalConsistency": logical_consistency,
|
| 146 |
+
"factualReliability": factual_reliability,
|
| 147 |
+
"selfCorrectionTriggered": self_correction_triggered,
|
| 148 |
+
},
|
| 149 |
+
"radarData": _build_radar(
|
| 150 |
+
confidence_score=confidence_score,
|
| 151 |
+
logical_consistency=logical_consistency,
|
| 152 |
+
factual_reliability=factual_reliability,
|
| 153 |
+
self_correction_triggered=self_correction_triggered,
|
| 154 |
+
issue_count=len(issues),
|
| 155 |
+
high_severity_count=severity_count["high"],
|
| 156 |
+
),
|
| 157 |
+
"issues": issues,
|
| 158 |
+
}
|
schemas/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas.schema import (
|
| 2 |
+
RegisterRequest,
|
| 3 |
+
LoginRequest,
|
| 4 |
+
QueryRequest,
|
| 5 |
+
TaskRequest,
|
| 6 |
+
SmartOrchestratorRequest,
|
| 7 |
+
AgentState,
|
| 8 |
+
OrchestratorState,
|
| 9 |
+
CodeModeState,
|
| 10 |
+
CodingSubtask,
|
| 11 |
+
CodingAgentState,
|
| 12 |
+
RenameRequest
|
| 13 |
+
)
|
schemas/schema.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import operator
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from langchain_core.messages import BaseMessage
|
| 4 |
+
from typing import TypedDict, Annotated, Sequence, List, Optional
|
| 5 |
+
|
| 6 |
+
class RegisterRequest(BaseModel):
|
| 7 |
+
email: str
|
| 8 |
+
password: str
|
| 9 |
+
display_name: str | None = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LoginRequest(BaseModel):
|
| 13 |
+
email: str
|
| 14 |
+
password: str
|
| 15 |
+
|
| 16 |
+
class QueryRequest(BaseModel):
|
| 17 |
+
query: str
|
| 18 |
+
conversation_id: str | None = None
|
| 19 |
+
pdfs: list[str] | None = None
|
| 20 |
+
|
| 21 |
+
class DebateRequest(BaseModel):
|
| 22 |
+
topic: str
|
| 23 |
+
rounds: int = 3
|
| 24 |
+
|
| 25 |
+
class TaskRequest(BaseModel):
|
| 26 |
+
task: str
|
| 27 |
+
conversation_id: str | None = None
|
| 28 |
+
|
| 29 |
+
class SmartOrchestratorRequest(BaseModel):
|
| 30 |
+
task: str
|
| 31 |
+
conversation_id: str | None = None
|
| 32 |
+
pdfs: list[str] | None = None
|
| 33 |
+
|
| 34 |
+
class AgentState(TypedDict):
|
| 35 |
+
messages: Annotated[Sequence[BaseMessage], operator.add]
|
| 36 |
+
|
| 37 |
+
class RenameRequest(BaseModel):
|
| 38 |
+
title: str
|
| 39 |
+
|
| 40 |
+
class OrchestratorState(TypedDict):
|
| 41 |
+
original_task: str
|
| 42 |
+
subtasks: List[dict] # [{id, description, agent_type, result}]
|
| 43 |
+
current_subtask_index: int
|
| 44 |
+
final_result: str
|
| 45 |
+
step_logs: List[str]
|
| 46 |
+
critic_confidence: int
|
| 47 |
+
critic_logical_consistency: int
|
| 48 |
+
critic_feedback: str
|
| 49 |
+
serious_mistakes: List[dict]
|
| 50 |
+
|
| 51 |
+
class CodeModeState(TypedDict):
|
| 52 |
+
original_task: str
|
| 53 |
+
plan: str
|
| 54 |
+
code_results: List[dict] # [{agent_id, code}]
|
| 55 |
+
final_code: str
|
| 56 |
+
review_feedback: str
|
| 57 |
+
confidence_score: int
|
| 58 |
+
consistency_score: int
|
| 59 |
+
step_logs: List[str]
|
| 60 |
+
serious_mistakes: List[dict]
|
| 61 |
+
graph_nodes: List[dict]
|
| 62 |
+
graph_edges: List[dict]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class CodingSubtask(TypedDict):
|
| 66 |
+
id: int
|
| 67 |
+
description: str
|
| 68 |
+
signatures: List[str]
|
| 69 |
+
result: Optional[str]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class CodingAgentState(TypedDict):
|
| 73 |
+
original_task: str
|
| 74 |
+
subtasks: List[CodingSubtask]
|
| 75 |
+
shared_contract: str
|
| 76 |
+
coder_results: List[str]
|
| 77 |
+
merged_code: str
|
| 78 |
+
review_errors: List[str]
|
| 79 |
+
retry_count: int
|
| 80 |
+
confidence_score: int
|
| 81 |
+
logical_consistency: int
|
| 82 |
+
critic_feedback: str
|
| 83 |
+
final_output: str
|
| 84 |
+
parsed_files: List[dict] # [{"filename": str, "content": str, "language": str}]
|
| 85 |
+
step_logs: List[str]
|
| 86 |
+
|
services/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Services module — re-exports service-level orchestration functions
|
| 2 |
+
from services.agent_service import (
|
| 3 |
+
run_tool_agent,
|
| 4 |
+
run_tool_agent_stream_sse
|
| 5 |
+
)
|
| 6 |
+
from services.deep_research_service import (
|
| 7 |
+
run_deep_research,
|
| 8 |
+
run_deep_research_stream_with_state,
|
| 9 |
+
_to_non_empty_text
|
| 10 |
+
)
|
| 11 |
+
from services.debate_service import (
|
| 12 |
+
run_debate_stream,
|
| 13 |
+
run_debate_stream_raw,
|
| 14 |
+
run_debate_stream_autogen,
|
| 15 |
+
structure_debate_rounds
|
| 16 |
+
)
|
| 17 |
+
from services.smart_orchestrator_service import (
|
| 18 |
+
smart_orchestrator_stream
|
| 19 |
+
)
|
| 20 |
+
from services.rag_service import (
|
| 21 |
+
run_smart_chat
|
| 22 |
+
)
|
| 23 |
+
from services.memory_service import (
|
| 24 |
+
get_conversation_memory_context,
|
| 25 |
+
get_conversation_memory_context_async,
|
| 26 |
+
add_to_memory,
|
| 27 |
+
clear_conversation_memory,
|
| 28 |
+
get_all_conversations
|
| 29 |
+
)
|
| 30 |
+
from services.context_injector import (
|
| 31 |
+
inject_memory_context,
|
| 32 |
+
has_memory_context,
|
| 33 |
+
log_context_injection
|
| 34 |
+
)
|
| 35 |
+
from services.base_stream_service import (
|
| 36 |
+
format_sse_event,
|
| 37 |
+
yield_sse_events,
|
| 38 |
+
yield_sse_with_error_handling,
|
| 39 |
+
create_sse_event
|
| 40 |
+
)
|
| 41 |
+
|
services/agent_service.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import AsyncGenerator
|
| 4 |
+
import utils.tools as _tools_module
|
| 5 |
+
from agents import get_tool_agent_graph, run_tool_agent_stream
|
| 6 |
+
from schemas.schema import AgentState
|
| 7 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 8 |
+
from services.memory_service import format_memory_block
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# ─── System instructions for tool agent ──────────
|
| 13 |
+
_SYSTEM_PROMPT = """
|
| 14 |
+
You have access to the following tools:
|
| 15 |
+
- knowledge_retriever: Use this tool to answer questions about uploaded PDF documents. Call it when user asks about document content.
|
| 16 |
+
- calculator: Use for mathematical calculations.
|
| 17 |
+
- get_current_datetime: Use when user asks about current time or date.
|
| 18 |
+
|
| 19 |
+
Instructions:
|
| 20 |
+
- Only call tools when necessary
|
| 21 |
+
- If you know the answer, respond directly without calling tools
|
| 22 |
+
- Do not guess or make up information
|
| 23 |
+
- When calling knowledge_retriever, include relevant search query
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def run_tool_agent(
|
| 28 |
+
query: str,
|
| 29 |
+
user_id: str = "default_user",
|
| 30 |
+
pdfs: list[str] | None = None,
|
| 31 |
+
memory_context: str | None = None,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Run the tool-calling agent and return answer + tool usage info.
|
| 35 |
+
|
| 36 |
+
Returns a dict with:
|
| 37 |
+
answer : str
|
| 38 |
+
tools_used : list[dict]
|
| 39 |
+
message_count : int
|
| 40 |
+
retrieved_chunks: list[dict] ← all chunks buffered during this call,
|
| 41 |
+
to be logged by the caller after append_message
|
| 42 |
+
returns the real message_id.
|
| 43 |
+
"""
|
| 44 |
+
# ── Set per-request context; clear chunk buffer from any previous request ─
|
| 45 |
+
_tools_module.CURRENT_USER_ID = user_id
|
| 46 |
+
_tools_module.RETRIEVED_CHUNKS_BUFFER.clear()
|
| 47 |
+
|
| 48 |
+
# ── Build the message content (inject prior history if available) ─────────
|
| 49 |
+
if memory_context:
|
| 50 |
+
logger.info(
|
| 51 |
+
f"[agent_service] Injecting memory context ({len(memory_context)} chars) into tool agent prompt."
|
| 52 |
+
)
|
| 53 |
+
message_content = format_memory_block(memory_context) + query + _SYSTEM_PROMPT
|
| 54 |
+
else:
|
| 55 |
+
logger.info(
|
| 56 |
+
"[agent_service] No memory context — running tool agent without history."
|
| 57 |
+
)
|
| 58 |
+
message_content = query + _SYSTEM_PROMPT
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
graph = get_tool_agent_graph()
|
| 62 |
+
initial_state: AgentState = {
|
| 63 |
+
"messages": [HumanMessage(content=message_content)]
|
| 64 |
+
}
|
| 65 |
+
result = graph.invoke(initial_state)
|
| 66 |
+
messages = result["messages"]
|
| 67 |
+
|
| 68 |
+
tools_used = []
|
| 69 |
+
final_answer = ""
|
| 70 |
+
|
| 71 |
+
for msg in messages:
|
| 72 |
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
| 73 |
+
for tc in msg.tool_calls:
|
| 74 |
+
tools_used.append({"tool": tc["name"], "args": tc["args"]})
|
| 75 |
+
if (
|
| 76 |
+
isinstance(msg, AIMessage)
|
| 77 |
+
and msg.content
|
| 78 |
+
and not (hasattr(msg, "tool_calls") and msg.tool_calls)
|
| 79 |
+
):
|
| 80 |
+
final_answer = msg.content
|
| 81 |
+
|
| 82 |
+
# Snapshot the buffer — caller uses this after append_message to log with correct message_id
|
| 83 |
+
retrieved_chunks = list(_tools_module.RETRIEVED_CHUNKS_BUFFER)
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
"answer": final_answer,
|
| 87 |
+
"tools_used": tools_used,
|
| 88 |
+
"message_count": len(messages),
|
| 89 |
+
"retrieved_chunks": retrieved_chunks,
|
| 90 |
+
}
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"[agent_service] Error: {e}", exc_info=True)
|
| 93 |
+
raise
|
| 94 |
+
finally:
|
| 95 |
+
# Always clean up — prevents chunks from leaking into the next request
|
| 96 |
+
_tools_module.CURRENT_USER_ID = "default_user"
|
| 97 |
+
_tools_module.RETRIEVED_CHUNKS_BUFFER.clear()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def run_tool_agent_stream_sse(
|
| 101 |
+
query: str,
|
| 102 |
+
user_id: str = "default_user",
|
| 103 |
+
pdfs: list[str] | None = None,
|
| 104 |
+
memory_context: str | None = None,
|
| 105 |
+
) -> AsyncGenerator[str, None]:
|
| 106 |
+
"""
|
| 107 |
+
Run the tool-calling agent with SSE streaming (3-phase: initial → tools → final).
|
| 108 |
+
|
| 109 |
+
Tokens are batched (~50ms windows) to reduce SSE event overhead
|
| 110 |
+
and produce smooth, low-latency streaming on the frontend.
|
| 111 |
+
|
| 112 |
+
Yields SSE-formatted event strings:
|
| 113 |
+
data: {"type": "token", "content": "...", "phase": "initial"|"final"}
|
| 114 |
+
data: {"type": "tool_start", "tool_name": "...", "tool_args": {...}}
|
| 115 |
+
data: {"type": "tool_end", "tool_name": "...", "tool_output": "..."}
|
| 116 |
+
data: {"type": "done", "answer": "...", "tools_used": [...], "retrieved_chunks": [...]}
|
| 117 |
+
"""
|
| 118 |
+
# ── Set per-request context; clear chunk buffer ─
|
| 119 |
+
_tools_module.CURRENT_USER_ID = user_id
|
| 120 |
+
_tools_module.RETRIEVED_CHUNKS_BUFFER.clear()
|
| 121 |
+
|
| 122 |
+
# ── Build message content ─
|
| 123 |
+
if memory_context:
|
| 124 |
+
logger.info(
|
| 125 |
+
f"[agent_service:stream] Injecting memory context ({len(memory_context)} chars)"
|
| 126 |
+
)
|
| 127 |
+
message_content = format_memory_block(memory_context) + query + _SYSTEM_PROMPT
|
| 128 |
+
else:
|
| 129 |
+
logger.info("[agent_service:stream] No memory context — fresh run")
|
| 130 |
+
message_content = query + _SYSTEM_PROMPT
|
| 131 |
+
|
| 132 |
+
messages = [HumanMessage(content=message_content)]
|
| 133 |
+
final_answer = ""
|
| 134 |
+
tools_used = []
|
| 135 |
+
retrieved_chunks = []
|
| 136 |
+
|
| 137 |
+
def _sse(event: dict) -> str:
|
| 138 |
+
return f"data: {json.dumps(event)}\n\n"
|
| 139 |
+
|
| 140 |
+
# Token batching: accumulate tokens and flush periodically
|
| 141 |
+
token_buffer: dict[str, str] = {"initial": "", "final": ""}
|
| 142 |
+
last_flush = 0.0
|
| 143 |
+
FLUSH_INTERVAL = 0.025 # 25ms — smooth without overwhelming the network
|
| 144 |
+
|
| 145 |
+
import time
|
| 146 |
+
|
| 147 |
+
async def _flush_tokens(force: bool = False):
|
| 148 |
+
nonlocal last_flush
|
| 149 |
+
now = time.monotonic()
|
| 150 |
+
if not force and (now - last_flush) < FLUSH_INTERVAL:
|
| 151 |
+
return
|
| 152 |
+
for phase in ("initial", "final"):
|
| 153 |
+
if token_buffer[phase]:
|
| 154 |
+
yield _sse(
|
| 155 |
+
{
|
| 156 |
+
"type": "token",
|
| 157 |
+
"content": token_buffer[phase],
|
| 158 |
+
"phase": phase,
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
token_buffer[phase] = ""
|
| 162 |
+
last_flush = now
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
logger.info("[agent_service:stream] Starting 3-phase streaming execution...")
|
| 166 |
+
|
| 167 |
+
async for event in run_tool_agent_stream(messages):
|
| 168 |
+
evt_type = event.get("type")
|
| 169 |
+
|
| 170 |
+
if evt_type == "token":
|
| 171 |
+
# Accumulate into phase buffer instead of yielding immediately
|
| 172 |
+
phase = event.get("phase", "initial")
|
| 173 |
+
token_buffer[phase] += event["content"]
|
| 174 |
+
# Flush if buffer is getting large (>300 chars)
|
| 175 |
+
if len(token_buffer[phase]) > 300:
|
| 176 |
+
async for sse_line in _flush_tokens(force=True):
|
| 177 |
+
yield sse_line
|
| 178 |
+
|
| 179 |
+
elif evt_type == "tool_start":
|
| 180 |
+
# Flush any pending tokens before tool execution
|
| 181 |
+
async for sse_line in _flush_tokens(force=True):
|
| 182 |
+
yield sse_line
|
| 183 |
+
logger.info(
|
| 184 |
+
f"[agent_service:stream] Tool starting: {event['tool_name']}"
|
| 185 |
+
)
|
| 186 |
+
yield _sse(
|
| 187 |
+
{
|
| 188 |
+
"type": "tool_start",
|
| 189 |
+
"tool_name": event["tool_name"],
|
| 190 |
+
"tool_args": event["tool_args"],
|
| 191 |
+
}
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
elif evt_type == "tool_end":
|
| 195 |
+
logger.info(
|
| 196 |
+
f"[agent_service:stream] Tool completed: {event['tool_name']}"
|
| 197 |
+
)
|
| 198 |
+
yield _sse(
|
| 199 |
+
{
|
| 200 |
+
"type": "tool_end",
|
| 201 |
+
"tool_name": event["tool_name"],
|
| 202 |
+
"tool_output": event["tool_output"],
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
elif evt_type == "complete":
|
| 207 |
+
# Flush remaining tokens
|
| 208 |
+
async for sse_line in _flush_tokens(force=True):
|
| 209 |
+
yield sse_line
|
| 210 |
+
final_answer = event["answer"]
|
| 211 |
+
tools_used = event["tools_used"]
|
| 212 |
+
retrieved_chunks = list(_tools_module.RETRIEVED_CHUNKS_BUFFER)
|
| 213 |
+
|
| 214 |
+
logger.info(
|
| 215 |
+
f"[agent_service:stream] Complete. Answer: {len(final_answer)} chars, "
|
| 216 |
+
f"Tools: {len(tools_used)}, Chunks: {len(retrieved_chunks)}"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
yield _sse(
|
| 220 |
+
{
|
| 221 |
+
"type": "done",
|
| 222 |
+
"answer": final_answer,
|
| 223 |
+
"tools_used": tools_used,
|
| 224 |
+
"retrieved_chunks": retrieved_chunks,
|
| 225 |
+
}
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"[agent_service:stream] Error: {e}", exc_info=True)
|
| 230 |
+
yield _sse({"type": "error", "message": str(e)})
|
| 231 |
+
raise
|
| 232 |
+
finally:
|
| 233 |
+
_tools_module.CURRENT_USER_ID = "default_user"
|
| 234 |
+
_tools_module.RETRIEVED_CHUNKS_BUFFER.clear()
|
services/base_stream_service.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base streaming service with shared SSE (Server-Sent Events) utilities.
|
| 3 |
+
|
| 4 |
+
Provides consistent patterns for:
|
| 5 |
+
- SSE event formatting
|
| 6 |
+
- Event yielding and streaming
|
| 7 |
+
- Error handling in streams
|
| 8 |
+
- Event queue management
|
| 9 |
+
|
| 10 |
+
Single source of truth for server-sent event generation across all services.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
from typing import Any, AsyncGenerator, Callable
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def format_sse_event(data: dict[str, Any]) -> str:
|
| 18 |
+
"""
|
| 19 |
+
Format a dict as SSE (Server-Sent Events) message.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
data: Dict to serialize as JSON event
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
SSE-formatted string: "data: {json}\n\n"
|
| 26 |
+
|
| 27 |
+
Example:
|
| 28 |
+
event = {"type": "token", "content": "hello"}
|
| 29 |
+
sse_message = format_sse_event(event)
|
| 30 |
+
# Returns: 'data: {"type": "token", "content": "hello"}\n\n'
|
| 31 |
+
"""
|
| 32 |
+
return f"data: {json.dumps(data)}\n\n"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def yield_sse_events(
|
| 36 |
+
event_source: AsyncGenerator[dict[str, Any], None],
|
| 37 |
+
) -> AsyncGenerator[str, None]:
|
| 38 |
+
"""
|
| 39 |
+
Convert dict events to SSE format.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
event_source: Async generator yielding event dicts
|
| 43 |
+
|
| 44 |
+
Yields:
|
| 45 |
+
SSE-formatted event strings
|
| 46 |
+
|
| 47 |
+
Example:
|
| 48 |
+
async def my_events():
|
| 49 |
+
yield {"type": "start"}
|
| 50 |
+
yield {"type": "token", "content": "hello"}
|
| 51 |
+
yield {"type": "done"}
|
| 52 |
+
|
| 53 |
+
async for sse_msg in yield_sse_events(my_events()):
|
| 54 |
+
await send(sse_msg)
|
| 55 |
+
"""
|
| 56 |
+
try:
|
| 57 |
+
async for event in event_source:
|
| 58 |
+
yield format_sse_event(event)
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
yield format_sse_event({
|
| 61 |
+
"type": "error",
|
| 62 |
+
"message": str(exc),
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def yield_sse_with_error_handling(
|
| 67 |
+
event_source: AsyncGenerator[dict[str, Any], None],
|
| 68 |
+
error_logger: Callable[[str], None] | None = None,
|
| 69 |
+
) -> AsyncGenerator[str, None]:
|
| 70 |
+
"""
|
| 71 |
+
Convert dict events to SSE format with error handling and logging.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
event_source: Async generator yielding event dicts
|
| 75 |
+
error_logger: Optional callback to log errors
|
| 76 |
+
|
| 77 |
+
Yields:
|
| 78 |
+
SSE-formatted event strings
|
| 79 |
+
"""
|
| 80 |
+
try:
|
| 81 |
+
async for event in event_source:
|
| 82 |
+
yield format_sse_event(event)
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
error_msg = str(exc)
|
| 85 |
+
if error_logger:
|
| 86 |
+
error_logger(f"SSE stream error: {error_msg}")
|
| 87 |
+
yield format_sse_event({
|
| 88 |
+
"type": "error",
|
| 89 |
+
"message": error_msg,
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def create_sse_event(
|
| 94 |
+
event_type: str,
|
| 95 |
+
**kwargs: Any,
|
| 96 |
+
) -> dict[str, Any]:
|
| 97 |
+
"""
|
| 98 |
+
Create a properly-structured SSE event dict.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
event_type: Type of event (e.g., "token", "done", "error")
|
| 102 |
+
**kwargs: Additional fields to include
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Dict ready to be formatted as SSE
|
| 106 |
+
|
| 107 |
+
Example:
|
| 108 |
+
event = create_sse_event("token", content="hello", phase="initial")
|
| 109 |
+
sse_msg = format_sse_event(event)
|
| 110 |
+
"""
|
| 111 |
+
return {"type": event_type, **kwargs}
|
services/context_injector.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Context injection utilities for memory and history management.
|
| 3 |
+
|
| 4 |
+
Provides consistent patterns for:
|
| 5 |
+
- Checking if memory/context is available
|
| 6 |
+
- Logging context injection
|
| 7 |
+
- Formatting and prepending context to tasks/prompts
|
| 8 |
+
- Context size tracking for monitoring
|
| 9 |
+
|
| 10 |
+
Single source of truth for memory injection patterns used across all services.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from services.memory_service import format_memory_block
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def inject_memory_context(
|
| 20 |
+
primary_content: str,
|
| 21 |
+
memory_context: str | None = None,
|
| 22 |
+
service_name: str = "agent",
|
| 23 |
+
context_type: str = "memory",
|
| 24 |
+
) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Inject memory/context into primary content with consistent logging.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
primary_content: Main content (task, query, prompt)
|
| 30 |
+
memory_context: Optional prior history/context to prepend
|
| 31 |
+
service_name: Name of calling service for logging (e.g., "agent_service", "orchestrator")
|
| 32 |
+
context_type: Type of context for logging clarity (e.g., "memory", "history", "prior_conversation")
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Combined content with memory prepended if available, otherwise primary_content as-is
|
| 36 |
+
|
| 37 |
+
Example:
|
| 38 |
+
effective_task = inject_memory_context(
|
| 39 |
+
primary_content=task,
|
| 40 |
+
memory_context=user_history,
|
| 41 |
+
service_name="coding_service",
|
| 42 |
+
context_type="conversation"
|
| 43 |
+
)
|
| 44 |
+
"""
|
| 45 |
+
if memory_context:
|
| 46 |
+
logger.info(
|
| 47 |
+
f"[{service_name}] Injecting {context_type} context ({len(memory_context)} chars) into task."
|
| 48 |
+
)
|
| 49 |
+
return format_memory_block(memory_context) + primary_content
|
| 50 |
+
else:
|
| 51 |
+
logger.info(
|
| 52 |
+
f"[{service_name}] No {context_type} context available — proceeding without prior history."
|
| 53 |
+
)
|
| 54 |
+
return primary_content
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def has_memory_context(memory_context: str | None) -> bool:
|
| 58 |
+
"""
|
| 59 |
+
Check if memory context is available (non-None and non-empty).
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
memory_context: Potential context string
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
True if context exists and is non-empty, False otherwise
|
| 66 |
+
"""
|
| 67 |
+
return bool(memory_context and memory_context.strip())
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def log_context_injection(
|
| 71 |
+
service_name: str,
|
| 72 |
+
context_size: int | None = None,
|
| 73 |
+
context_type: str = "memory",
|
| 74 |
+
action: str = "injecting",
|
| 75 |
+
) -> None:
|
| 76 |
+
"""
|
| 77 |
+
Log context injection with standardized format.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
service_name: Name of calling service
|
| 81 |
+
context_size: Optional size in bytes/chars for monitoring
|
| 82 |
+
context_type: Type of context (memory, history, etc.)
|
| 83 |
+
action: Action being taken (injecting, skipping, loading, etc.)
|
| 84 |
+
|
| 85 |
+
Example:
|
| 86 |
+
log_context_injection("orchestrator_service", 245, "conversation", "injecting")
|
| 87 |
+
# Output: [orchestrator_service] Injecting 245 chars of conversation context
|
| 88 |
+
"""
|
| 89 |
+
if context_size:
|
| 90 |
+
logger.info(
|
| 91 |
+
f"[{service_name}] {action.capitalize()} {context_size} chars of {context_type} context"
|
| 92 |
+
)
|
| 93 |
+
else:
|
| 94 |
+
logger.info(f"[{service_name}] {action.capitalize()} {context_type} context")
|
services/debate_service.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import inspect
|
| 3 |
+
from typing import Any, AsyncGenerator
|
| 4 |
+
|
| 5 |
+
from autogen_core import CancellationToken
|
| 6 |
+
from autogen_agentchat.messages import TextMessage
|
| 7 |
+
|
| 8 |
+
from core import get_client
|
| 9 |
+
from agents import (
|
| 10 |
+
create_critic_agent,
|
| 11 |
+
create_proposer_agent,
|
| 12 |
+
create_verifier_agent,
|
| 13 |
+
get_agent_a_persona,
|
| 14 |
+
get_agent_b_persona,
|
| 15 |
+
get_debate_model,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _phase_for_round(round_num: int, total_rounds: int) -> str:
|
| 20 |
+
if round_num == 1:
|
| 21 |
+
return "opening statement"
|
| 22 |
+
if round_num == total_rounds:
|
| 23 |
+
return "closing rebuttal"
|
| 24 |
+
return "rebuttal round"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _format_transcript(rounds_data: dict[int, dict[str, str]]) -> str:
|
| 28 |
+
transcript_lines: list[str] = []
|
| 29 |
+
for rnd in sorted(rounds_data.keys()):
|
| 30 |
+
proposer = rounds_data[rnd].get("proposer", "").strip()
|
| 31 |
+
critic = rounds_data[rnd].get("critic", "").strip()
|
| 32 |
+
transcript_lines.append(f"Round {rnd} - FOR: {proposer}")
|
| 33 |
+
transcript_lines.append(f"Round {rnd} - AGAINST: {critic}")
|
| 34 |
+
return "\n".join(transcript_lines)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _content_to_text(content: Any) -> str:
|
| 38 |
+
if isinstance(content, str):
|
| 39 |
+
return content
|
| 40 |
+
if isinstance(content, list):
|
| 41 |
+
chunks: list[str] = []
|
| 42 |
+
for item in content:
|
| 43 |
+
if isinstance(item, str):
|
| 44 |
+
chunks.append(item)
|
| 45 |
+
elif isinstance(item, dict):
|
| 46 |
+
text = item.get("text")
|
| 47 |
+
if isinstance(text, str):
|
| 48 |
+
chunks.append(text)
|
| 49 |
+
return "\n".join(part for part in chunks if part.strip())
|
| 50 |
+
raise ValueError(f"Unsupported AutoGen message content type: {type(content)}")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def _agent_reply(agent: Any, prompt: str) -> str:
|
| 54 |
+
response = await agent.on_messages(
|
| 55 |
+
[TextMessage(content=prompt, source="user")],
|
| 56 |
+
cancellation_token=CancellationToken(),
|
| 57 |
+
)
|
| 58 |
+
chat_message = getattr(response, "chat_message", None)
|
| 59 |
+
if chat_message is None:
|
| 60 |
+
raise ValueError("AutoGen response did not include chat_message.")
|
| 61 |
+
|
| 62 |
+
text = _content_to_text(getattr(chat_message, "content", ""))
|
| 63 |
+
cleaned = text.strip()
|
| 64 |
+
if not cleaned:
|
| 65 |
+
raise ValueError("AutoGen response content is empty.")
|
| 66 |
+
return cleaned
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def _close_agent_model_client(agent: Any) -> None:
|
| 70 |
+
model_client = getattr(agent, "model_client", None)
|
| 71 |
+
if model_client is None:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
close_fn = getattr(model_client, "close", None)
|
| 75 |
+
if close_fn is None:
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
result = close_fn()
|
| 79 |
+
if inspect.isawaitable(result):
|
| 80 |
+
await result
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def run_debate_stream_raw(topic: str, rounds: int = 3) -> AsyncGenerator[dict, None]:
|
| 84 |
+
"""Stream a debate using raw Groq API (original implementation)."""
|
| 85 |
+
groq_client = get_client()
|
| 86 |
+
model = get_debate_model()
|
| 87 |
+
persona_a = get_agent_a_persona()
|
| 88 |
+
persona_b = get_agent_b_persona()
|
| 89 |
+
|
| 90 |
+
history_a = []
|
| 91 |
+
history_b = []
|
| 92 |
+
|
| 93 |
+
yield {"type": "info", "message": f"🎭 Debate started: '{topic}' | {rounds} rounds"}
|
| 94 |
+
await asyncio.sleep(0.2)
|
| 95 |
+
|
| 96 |
+
last_a_message = ""
|
| 97 |
+
last_b_message = ""
|
| 98 |
+
|
| 99 |
+
for round_num in range(1, rounds + 1):
|
| 100 |
+
yield {"type": "round", "round": round_num, "total_rounds": rounds}
|
| 101 |
+
await asyncio.sleep(0.1)
|
| 102 |
+
|
| 103 |
+
# Agent A (Proposer)
|
| 104 |
+
if round_num == 1:
|
| 105 |
+
user_msg_a = f'The debate topic is: "{topic}". You are arguing FOR this position. Give your opening statement.'
|
| 106 |
+
else:
|
| 107 |
+
user_msg_a = f'Agent Critic said: "{last_b_message}"\nContinue the debate. Counter their argument and strengthen your position. Round {round_num} of {rounds}.'
|
| 108 |
+
|
| 109 |
+
history_a.append({"role": "user", "content": user_msg_a})
|
| 110 |
+
response_a = groq_client.chat.completions.create(
|
| 111 |
+
model=model,
|
| 112 |
+
messages=[{"role": "system", "content": persona_a}] + history_a,
|
| 113 |
+
temperature=0.7,
|
| 114 |
+
)
|
| 115 |
+
msg_a = response_a.choices[0].message.content
|
| 116 |
+
history_a.append({"role": "assistant", "content": msg_a})
|
| 117 |
+
last_a_message = msg_a
|
| 118 |
+
|
| 119 |
+
yield {
|
| 120 |
+
"type": "message",
|
| 121 |
+
"agent": "Agent Proposer",
|
| 122 |
+
"agent_id": "A",
|
| 123 |
+
"position": "FOR",
|
| 124 |
+
"round": round_num,
|
| 125 |
+
"content": msg_a,
|
| 126 |
+
}
|
| 127 |
+
await asyncio.sleep(0.1)
|
| 128 |
+
|
| 129 |
+
# Agent B (Critic)
|
| 130 |
+
if round_num == 1:
|
| 131 |
+
user_msg_b = f'The debate topic is: "{topic}". You are arguing AGAINST this position. Give your opening statement.'
|
| 132 |
+
else:
|
| 133 |
+
user_msg_b = f'Agent Proposer said: "{last_a_message}"\nRespond to their argument and reinforce your position. Round {round_num} of {rounds}.'
|
| 134 |
+
|
| 135 |
+
history_b.append({"role": "user", "content": user_msg_b})
|
| 136 |
+
response_b = groq_client.chat.completions.create(
|
| 137 |
+
model=model,
|
| 138 |
+
messages=[{"role": "system", "content": persona_b}] + history_b,
|
| 139 |
+
temperature=0.7,
|
| 140 |
+
)
|
| 141 |
+
msg_b = response_b.choices[0].message.content
|
| 142 |
+
history_b.append({"role": "assistant", "content": msg_b})
|
| 143 |
+
last_b_message = msg_b
|
| 144 |
+
|
| 145 |
+
yield {
|
| 146 |
+
"type": "message",
|
| 147 |
+
"agent": "Agent Critic",
|
| 148 |
+
"agent_id": "B",
|
| 149 |
+
"position": "AGAINST",
|
| 150 |
+
"round": round_num,
|
| 151 |
+
"content": msg_b,
|
| 152 |
+
}
|
| 153 |
+
await asyncio.sleep(0.2)
|
| 154 |
+
|
| 155 |
+
# Verdict
|
| 156 |
+
yield {"type": "info", "message": "⚖️ Generating debate summary..."}
|
| 157 |
+
await asyncio.sleep(0.01)
|
| 158 |
+
|
| 159 |
+
all_debate = ""
|
| 160 |
+
for i, (ha, hb) in enumerate(zip(
|
| 161 |
+
[m for m in history_a if m["role"] == "assistant"],
|
| 162 |
+
[m for m in history_b if m["role"] == "assistant"],
|
| 163 |
+
)):
|
| 164 |
+
all_debate += f"\nRound {i+1} - FOR: {ha['content']}\nRound {i+1} - AGAINST: {hb['content']}\n"
|
| 165 |
+
|
| 166 |
+
verdict_response = groq_client.chat.completions.create(
|
| 167 |
+
model=model,
|
| 168 |
+
messages=[{
|
| 169 |
+
"role": "user",
|
| 170 |
+
"content": f"""Topic: "{topic}"
|
| 171 |
+
|
| 172 |
+
Debate transcript:
|
| 173 |
+
{all_debate}
|
| 174 |
+
|
| 175 |
+
As an impartial judge, summarize the key arguments made by both sides and provide a balanced verdict on who made stronger arguments and why. Be concise (6-7 sentences)."""
|
| 176 |
+
}],
|
| 177 |
+
temperature=0.3,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
verdict = verdict_response.choices[0].message.content
|
| 181 |
+
|
| 182 |
+
yield {
|
| 183 |
+
"type": "verdict",
|
| 184 |
+
"content": verdict,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def run_debate_stream_autogen(topic: str, rounds: int = 3) -> AsyncGenerator[dict, None]:
|
| 189 |
+
"""Stream a debate between Agent Proposer and Agent Critic using AutoGen."""
|
| 190 |
+
proposer_agent = create_proposer_agent()
|
| 191 |
+
critic_agent = create_critic_agent()
|
| 192 |
+
verifier_agent = create_verifier_agent()
|
| 193 |
+
|
| 194 |
+
rounds_data: dict[int, dict[str, str]] = {}
|
| 195 |
+
|
| 196 |
+
yield {"type": "info", "message": f"🎭 Debate started: '{topic}' | {rounds} rounds"}
|
| 197 |
+
await asyncio.sleep(0.2)
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
for round_num in range(1, rounds + 1):
|
| 201 |
+
phase = _phase_for_round(round_num, rounds)
|
| 202 |
+
yield {"type": "round", "round": round_num, "total_rounds": rounds}
|
| 203 |
+
await asyncio.sleep(0.1)
|
| 204 |
+
|
| 205 |
+
transcript = _format_transcript(rounds_data)
|
| 206 |
+
proposer_prompt = (
|
| 207 |
+
f"Debate topic: \"{topic}\"\n"
|
| 208 |
+
f"Current phase: {phase}\n"
|
| 209 |
+
"Role: You must argue FOR the topic.\n"
|
| 210 |
+
"Rules: Provide one clear claim, one support, and one direct rebuttal "
|
| 211 |
+
"to the strongest opposing point seen so far.\n"
|
| 212 |
+
f"Transcript so far:\n{transcript if transcript else 'No prior rounds.'}\n"
|
| 213 |
+
"Respond in 3-4 sentences."
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
proposer_msg = await _agent_reply(proposer_agent, proposer_prompt)
|
| 217 |
+
if round_num not in rounds_data:
|
| 218 |
+
rounds_data[round_num] = {"proposer": "", "critic": ""}
|
| 219 |
+
rounds_data[round_num]["proposer"] = proposer_msg
|
| 220 |
+
|
| 221 |
+
yield {
|
| 222 |
+
"type": "message",
|
| 223 |
+
"agent": "Agent Proposer",
|
| 224 |
+
"agent_id": "A",
|
| 225 |
+
"position": "FOR",
|
| 226 |
+
"round": round_num,
|
| 227 |
+
"content": proposer_msg,
|
| 228 |
+
}
|
| 229 |
+
await asyncio.sleep(0.4)
|
| 230 |
+
|
| 231 |
+
transcript = _format_transcript(rounds_data)
|
| 232 |
+
critic_prompt = (
|
| 233 |
+
f"Debate topic: \"{topic}\"\n"
|
| 234 |
+
f"Current phase: {phase}\n"
|
| 235 |
+
"Role: You must argue AGAINST the topic.\n"
|
| 236 |
+
"Rules: Address Proposer's latest claim directly, expose one weakness, "
|
| 237 |
+
"and present one counter-claim with support.\n"
|
| 238 |
+
f"Transcript so far:\n{transcript}\n"
|
| 239 |
+
"Respond in 3-4 sentences."
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
critic_msg = await _agent_reply(critic_agent, critic_prompt)
|
| 243 |
+
rounds_data[round_num]["critic"] = critic_msg
|
| 244 |
+
|
| 245 |
+
yield {
|
| 246 |
+
"type": "message",
|
| 247 |
+
"agent": "Agent Critic",
|
| 248 |
+
"agent_id": "B",
|
| 249 |
+
"position": "AGAINST",
|
| 250 |
+
"round": round_num,
|
| 251 |
+
"content": critic_msg,
|
| 252 |
+
}
|
| 253 |
+
await asyncio.sleep(0.2)
|
| 254 |
+
|
| 255 |
+
yield {"type": "info", "message": "⚖️ Generating debate summary..."}
|
| 256 |
+
await asyncio.sleep(0.01)
|
| 257 |
+
|
| 258 |
+
final_transcript = _format_transcript(rounds_data)
|
| 259 |
+
verdict_prompt = (
|
| 260 |
+
f"Topic: \"{topic}\"\n"
|
| 261 |
+
"You are the impartial verifier. Evaluate the debate below.\n"
|
| 262 |
+
"Scoring criteria: argument strength, evidence quality, and logical consistency.\n"
|
| 263 |
+
"Output requirements: 6-7 sentences, balanced summary, and explicit winner with reason.\n"
|
| 264 |
+
f"Debate transcript:\n{final_transcript}"
|
| 265 |
+
)
|
| 266 |
+
verdict = await _agent_reply(verifier_agent, verdict_prompt)
|
| 267 |
+
|
| 268 |
+
yield {
|
| 269 |
+
"type": "verdict",
|
| 270 |
+
"content": verdict,
|
| 271 |
+
}
|
| 272 |
+
finally:
|
| 273 |
+
await _close_agent_model_client(proposer_agent)
|
| 274 |
+
await _close_agent_model_client(critic_agent)
|
| 275 |
+
await _close_agent_model_client(verifier_agent)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
async def run_debate_stream(
|
| 279 |
+
topic: str,
|
| 280 |
+
rounds: int = 3,
|
| 281 |
+
mode: str = "autogen"
|
| 282 |
+
) -> AsyncGenerator[dict, None]:
|
| 283 |
+
"""
|
| 284 |
+
Route to appropriate debate implementation based on mode.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
topic: Debate topic
|
| 288 |
+
rounds: Number of debate rounds
|
| 289 |
+
mode: "raw" for original Groq API, "autogen" for AutoGen orchestration
|
| 290 |
+
|
| 291 |
+
Yields:
|
| 292 |
+
SSE events (same format for both modes)
|
| 293 |
+
|
| 294 |
+
Raises:
|
| 295 |
+
ValueError: If mode is not "raw" or "autogen"
|
| 296 |
+
"""
|
| 297 |
+
if mode.lower() not in ("raw", "autogen"):
|
| 298 |
+
raise ValueError(f"Invalid debate mode: {mode}. Must be 'raw' or 'autogen'.")
|
| 299 |
+
|
| 300 |
+
if mode.lower() == "raw":
|
| 301 |
+
async for event in run_debate_stream_raw(topic, rounds):
|
| 302 |
+
yield event
|
| 303 |
+
else: # autogen
|
| 304 |
+
async for event in run_debate_stream_autogen(topic, rounds):
|
| 305 |
+
yield event
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def structure_debate_rounds(debate_events: list[dict]) -> list[dict]:
|
| 310 |
+
"""
|
| 311 |
+
Convert raw debate events into structured rounds format:
|
| 312 |
+
[{"proposer": "...", "critic": "..."}, ...]
|
| 313 |
+
"""
|
| 314 |
+
rounds_data: dict[int, dict] = {}
|
| 315 |
+
for evt in debate_events:
|
| 316 |
+
if evt.get("type") == "message":
|
| 317 |
+
rnd = evt.get("round", 0)
|
| 318 |
+
if rnd not in rounds_data:
|
| 319 |
+
rounds_data[rnd] = {"proposer": "", "critic": ""}
|
| 320 |
+
if evt.get("agent_id") == "A":
|
| 321 |
+
rounds_data[rnd]["proposer"] = evt.get("content", "")
|
| 322 |
+
elif evt.get("agent_id") == "B":
|
| 323 |
+
rounds_data[rnd]["critic"] = evt.get("content", "")
|
| 324 |
+
return [rounds_data[k] for k in sorted(rounds_data.keys())]
|
services/deep_research_service.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import AsyncGenerator
|
| 3 |
+
from agents import get_deep_research_graph
|
| 4 |
+
from schemas.schema import OrchestratorState
|
| 5 |
+
from services.memory_service import format_memory_block
|
| 6 |
+
|
| 7 |
+
def _to_non_empty_text(value) -> str:
|
| 8 |
+
if value is None:
|
| 9 |
+
return ""
|
| 10 |
+
if isinstance(value, str):
|
| 11 |
+
return value.strip()
|
| 12 |
+
if isinstance(value, (dict, list)):
|
| 13 |
+
try:
|
| 14 |
+
return json.dumps(value, ensure_ascii=False).strip()
|
| 15 |
+
except Exception:
|
| 16 |
+
return str(value).strip()
|
| 17 |
+
return str(value).strip()
|
| 18 |
+
|
| 19 |
+
async def run_deep_research(task: str, memory_context: str | None = None) -> dict:
|
| 20 |
+
"""Run the deep_research pipeline: planner → parallel researchers → aggregator → critic.
|
| 21 |
+
|
| 22 |
+
If memory_context is provided (prior conversation history), it is prepended to the
|
| 23 |
+
task so the deep_research planner has awareness of the ongoing thread.
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
graph = get_deep_research_graph()
|
| 27 |
+
|
| 28 |
+
# Inject prior conversation context into the task
|
| 29 |
+
effective_task = task
|
| 30 |
+
if memory_context:
|
| 31 |
+
print(f"[deep_research_service] Injecting memory context ({len(memory_context)} chars) into deep_research task.")
|
| 32 |
+
effective_task = format_memory_block(memory_context) + task
|
| 33 |
+
else:
|
| 34 |
+
print("[deep_research_service] No memory context — running deep_research without history.")
|
| 35 |
+
|
| 36 |
+
initial_state: OrchestratorState = {
|
| 37 |
+
"original_task": effective_task,
|
| 38 |
+
"subtasks": [],
|
| 39 |
+
"current_subtask_index": 0,
|
| 40 |
+
"final_result": "",
|
| 41 |
+
"step_logs": [],
|
| 42 |
+
"critic_confidence": 0,
|
| 43 |
+
"critic_logical_consistency": 0,
|
| 44 |
+
"critic_feedback": "",
|
| 45 |
+
"serious_mistakes": [],
|
| 46 |
+
}
|
| 47 |
+
result = await graph.ainvoke(initial_state)
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
"final_result": result["final_result"],
|
| 51 |
+
"subtasks": [
|
| 52 |
+
{
|
| 53 |
+
"id": st["id"],
|
| 54 |
+
"description": st["description"],
|
| 55 |
+
"agent_type": st["agent_type"],
|
| 56 |
+
"result": st.get("result", ""),
|
| 57 |
+
}
|
| 58 |
+
for st in result["subtasks"]
|
| 59 |
+
],
|
| 60 |
+
"step_logs": result.get("step_logs", []),
|
| 61 |
+
"critic_confidence": result.get("critic_confidence", 85),
|
| 62 |
+
"critic_logical_consistency": result.get("critic_logical_consistency", 85),
|
| 63 |
+
"critic_feedback": result.get("critic_feedback", ""),
|
| 64 |
+
"serious_mistakes": result.get("serious_mistakes", []),
|
| 65 |
+
}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"[deep_research_service] Error: {e}")
|
| 68 |
+
raise
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def run_deep_research_stream(
|
| 72 |
+
task: str,
|
| 73 |
+
memory_context: str | None = None
|
| 74 |
+
) -> AsyncGenerator[dict, None]:
|
| 75 |
+
"""Stream the deep_research pipeline phase by phase.
|
| 76 |
+
|
| 77 |
+
Yields events as each node completes:
|
| 78 |
+
- {"type": "plan", "subtasks": [...]} — after deep_research creates subtasks
|
| 79 |
+
- {"type": "content_chunk", "section": "researcher_1", "content": "..."} — researcher 1 result
|
| 80 |
+
- {"type": "content_chunk", "section": "researcher_2", "content": "..."} — researcher 2 result
|
| 81 |
+
- {"type": "content_chunk", "section": "aggregation", "content": "..."} — aggregator result
|
| 82 |
+
- {"type": "final", "result": "...", "meta": {...}} — final result with critic scores
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
graph = get_deep_research_graph()
|
| 86 |
+
|
| 87 |
+
# Inject prior conversation context into the task
|
| 88 |
+
effective_task = task
|
| 89 |
+
if memory_context:
|
| 90 |
+
print(f"[deep_research_service:stream] Injecting memory context ({len(memory_context)} chars)")
|
| 91 |
+
effective_task = format_memory_block(memory_context) + task
|
| 92 |
+
else:
|
| 93 |
+
print("[deep_research_service:stream] No memory context — running deep_research without history.")
|
| 94 |
+
|
| 95 |
+
initial_state: OrchestratorState = {
|
| 96 |
+
"original_task": effective_task,
|
| 97 |
+
"subtasks": [],
|
| 98 |
+
"current_subtask_index": 0,
|
| 99 |
+
"final_result": "",
|
| 100 |
+
"step_logs": [],
|
| 101 |
+
"critic_confidence": 0,
|
| 102 |
+
"critic_logical_consistency": 0,
|
| 103 |
+
"critic_feedback": "",
|
| 104 |
+
"serious_mistakes": [],
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# Use astream to get events as each node completes
|
| 108 |
+
# LangGraph astream yields {node_name: state_update} dicts
|
| 109 |
+
async for event in graph.astream(initial_state, stream_mode="updates"):
|
| 110 |
+
# event is a dict like {"deep_research": {...}, "parallel_researchers": {...}, etc.}
|
| 111 |
+
for node_name, node_output in event.items():
|
| 112 |
+
if node_name == "deep_research":
|
| 113 |
+
# Subtasks created — emit plan event
|
| 114 |
+
subtasks = node_output.get("subtasks", [])
|
| 115 |
+
yield {
|
| 116 |
+
"type": "plan",
|
| 117 |
+
"subtasks": [
|
| 118 |
+
{
|
| 119 |
+
"id": st["id"],
|
| 120 |
+
"description": st["description"],
|
| 121 |
+
"agent_type": st["agent_type"],
|
| 122 |
+
}
|
| 123 |
+
for st in subtasks
|
| 124 |
+
],
|
| 125 |
+
}
|
| 126 |
+
# Also emit content_chunk for decomposition
|
| 127 |
+
subtask_descriptions = "\n".join(
|
| 128 |
+
f"- **Researcher {st['id']}:** {st['description']}"
|
| 129 |
+
for st in subtasks
|
| 130 |
+
if st["agent_type"] == "researcher"
|
| 131 |
+
)
|
| 132 |
+
yield {
|
| 133 |
+
"type": "content_chunk",
|
| 134 |
+
"section": "decomposition",
|
| 135 |
+
"content": subtask_descriptions,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
elif node_name == "parallel_researchers":
|
| 139 |
+
# Researchers completed — emit each researcher's result
|
| 140 |
+
subtasks = node_output.get("subtasks", [])
|
| 141 |
+
researcher_idx = 0
|
| 142 |
+
for st in subtasks:
|
| 143 |
+
if st["agent_type"] == "researcher":
|
| 144 |
+
researcher_idx += 1
|
| 145 |
+
section_name = f"researcher_{researcher_idx}"
|
| 146 |
+
yield {
|
| 147 |
+
"type": "content_chunk",
|
| 148 |
+
"section": section_name,
|
| 149 |
+
"content": st.get("result", ""),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
elif node_name == "aggregator":
|
| 153 |
+
# Aggregator completed — emit synthesis
|
| 154 |
+
final_result = node_output.get("final_result", "")
|
| 155 |
+
yield {
|
| 156 |
+
"type": "content_chunk",
|
| 157 |
+
"section": "aggregation",
|
| 158 |
+
"content": final_result,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
elif node_name == "critic":
|
| 162 |
+
# Critic completed — emit final result with meta
|
| 163 |
+
confidence = node_output.get("critic_confidence", 85)
|
| 164 |
+
consistency = node_output.get("critic_logical_consistency", 85)
|
| 165 |
+
feedback = node_output.get("critic_feedback", "")
|
| 166 |
+
serious_mistakes = node_output.get("serious_mistakes", [])
|
| 167 |
+
|
| 168 |
+
# Get the final_result from the state (aggregator set it)
|
| 169 |
+
# We need to track state across nodes, so we'll emit what we have
|
| 170 |
+
yield {
|
| 171 |
+
"type": "critic_done",
|
| 172 |
+
"meta": {
|
| 173 |
+
"confidence_score": confidence,
|
| 174 |
+
"logical_consistency": consistency,
|
| 175 |
+
"critic_feedback": feedback,
|
| 176 |
+
"serious_mistakes": serious_mistakes,
|
| 177 |
+
},
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
print(f"[deep_research_service:stream] Error: {e}")
|
| 182 |
+
yield {"type": "error", "message": str(e)}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
async def run_deep_research_stream_with_state(
|
| 186 |
+
task: str,
|
| 187 |
+
memory_context: str | None = None
|
| 188 |
+
) -> AsyncGenerator[dict, None]:
|
| 189 |
+
"""Stream the deep_research pipeline, tracking full state across nodes.
|
| 190 |
+
|
| 191 |
+
This version accumulates state so we can emit the complete final_result
|
| 192 |
+
when the critic finishes.
|
| 193 |
+
"""
|
| 194 |
+
try:
|
| 195 |
+
graph = get_deep_research_graph()
|
| 196 |
+
|
| 197 |
+
# Inject prior conversation context into the task
|
| 198 |
+
effective_task = task
|
| 199 |
+
if memory_context:
|
| 200 |
+
print(f"[deep_research_service:stream] Injecting memory context ({len(memory_context)} chars)")
|
| 201 |
+
effective_task = format_memory_block(memory_context) + task
|
| 202 |
+
else:
|
| 203 |
+
print("[deep_research_service:stream] No memory context — running deep_research without history.")
|
| 204 |
+
|
| 205 |
+
initial_state: OrchestratorState = {
|
| 206 |
+
"original_task": effective_task,
|
| 207 |
+
"subtasks": [],
|
| 208 |
+
"current_subtask_index": 0,
|
| 209 |
+
"final_result": "",
|
| 210 |
+
"step_logs": [],
|
| 211 |
+
"critic_confidence": 0,
|
| 212 |
+
"critic_logical_consistency": 0,
|
| 213 |
+
"critic_feedback": "",
|
| 214 |
+
"serious_mistakes": [],
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Track accumulated state
|
| 218 |
+
accumulated_state = dict(initial_state)
|
| 219 |
+
|
| 220 |
+
# Use astream with mode="values" to get full state after each node
|
| 221 |
+
async for event in graph.astream(initial_state, stream_mode="values"):
|
| 222 |
+
# event is the full state after each node update
|
| 223 |
+
subtasks = event.get("subtasks", [])
|
| 224 |
+
final_result = event.get("final_result", "")
|
| 225 |
+
critic_confidence = event.get("critic_confidence", 0)
|
| 226 |
+
critic_logical_consistency = event.get("critic_logical_consistency", 0)
|
| 227 |
+
critic_feedback = event.get("critic_feedback", "")
|
| 228 |
+
serious_mistakes = event.get("serious_mistakes", [])
|
| 229 |
+
step_logs = event.get("step_logs", [])
|
| 230 |
+
|
| 231 |
+
# Determine which node just completed by checking step_logs
|
| 232 |
+
last_log = step_logs[-1] if step_logs else ""
|
| 233 |
+
|
| 234 |
+
if "Deep Research" in last_log and ("created" in last_log or "decomposed" in last_log):
|
| 235 |
+
# Subtasks created — emit plan event
|
| 236 |
+
yield {
|
| 237 |
+
"type": "plan",
|
| 238 |
+
"subtasks": [
|
| 239 |
+
{
|
| 240 |
+
"id": st["id"],
|
| 241 |
+
"description": st["description"],
|
| 242 |
+
"agent_type": st["agent_type"],
|
| 243 |
+
}
|
| 244 |
+
for st in subtasks
|
| 245 |
+
],
|
| 246 |
+
}
|
| 247 |
+
# Also emit content_chunk for decomposition
|
| 248 |
+
subtask_descriptions = "\n".join(
|
| 249 |
+
f"- **Researcher {st['id']}:** {st['description']}"
|
| 250 |
+
for st in subtasks
|
| 251 |
+
if st["agent_type"] == "researcher"
|
| 252 |
+
)
|
| 253 |
+
yield {
|
| 254 |
+
"type": "content_chunk",
|
| 255 |
+
"section": "decomposition",
|
| 256 |
+
"content": subtask_descriptions,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
elif "researchers completed" in last_log:
|
| 260 |
+
# Researchers completed — emit each researcher's result
|
| 261 |
+
researcher_idx = 0
|
| 262 |
+
for st in subtasks:
|
| 263 |
+
if st["agent_type"] == "researcher":
|
| 264 |
+
researcher_idx += 1
|
| 265 |
+
section_name = f"researcher_{researcher_idx}"
|
| 266 |
+
yield {
|
| 267 |
+
"type": "content_chunk",
|
| 268 |
+
"section": section_name,
|
| 269 |
+
"content": st.get("result", ""),
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
elif "Aggregator agent synthesized" in last_log:
|
| 273 |
+
# Aggregator completed — emit synthesis
|
| 274 |
+
yield {
|
| 275 |
+
"type": "content_chunk",
|
| 276 |
+
"section": "aggregation",
|
| 277 |
+
"content": final_result,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
elif "Critic agent evaluated" in last_log:
|
| 281 |
+
# Critic completed — emit final result with meta
|
| 282 |
+
yield {
|
| 283 |
+
"type": "final",
|
| 284 |
+
"result": final_result,
|
| 285 |
+
"meta": {
|
| 286 |
+
"confidence_score": critic_confidence,
|
| 287 |
+
"logical_consistency": critic_logical_consistency,
|
| 288 |
+
"critic_feedback": critic_feedback,
|
| 289 |
+
"serious_mistakes": serious_mistakes,
|
| 290 |
+
"retry_count": 0,
|
| 291 |
+
"tools_used": [
|
| 292 |
+
st.get("agent_type", "") + "Agent" for st in subtasks
|
| 293 |
+
],
|
| 294 |
+
"deep_research_raw": {
|
| 295 |
+
"subtasks": [
|
| 296 |
+
{
|
| 297 |
+
"id": st["id"],
|
| 298 |
+
"description": st["description"],
|
| 299 |
+
"agent_type": st["agent_type"],
|
| 300 |
+
"result": st.get("result", ""),
|
| 301 |
+
}
|
| 302 |
+
for st in subtasks
|
| 303 |
+
],
|
| 304 |
+
"final_result": final_result,
|
| 305 |
+
"critic_confidence": critic_confidence,
|
| 306 |
+
"critic_logical_consistency": critic_logical_consistency,
|
| 307 |
+
"critic_feedback": critic_feedback,
|
| 308 |
+
},
|
| 309 |
+
},
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
except Exception as e:
|
| 313 |
+
print(f"[deep_research_service:stream] Error: {e}")
|
| 314 |
+
yield {"type": "error", "message": str(e)}
|
services/memory_service.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
memory_service.py
|
| 3 |
+
|
| 4 |
+
Provides conversation window memory context for all agent pipelines.
|
| 5 |
+
|
| 6 |
+
Uses a global in-memory store to keep the last 4 message turns per conversation.
|
| 7 |
+
This avoids calling the database on each request and is much faster than
|
| 8 |
+
LLM-based summarization.
|
| 9 |
+
|
| 10 |
+
The store is updated after each query/response via add_to_memory().
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from typing import Dict, List, Optional, Tuple
|
| 14 |
+
import threading
|
| 15 |
+
|
| 16 |
+
# Global in-memory store: {conversation_id: [(user_msg, assistant_msg), ...]}
|
| 17 |
+
# Keeps last 4 turns per conversation
|
| 18 |
+
_conversation_memory: Dict[str, List[Tuple[str, str]]] = {}
|
| 19 |
+
_memory_lock = threading.Lock()
|
| 20 |
+
|
| 21 |
+
# Window size - number of message turns to keep
|
| 22 |
+
_WINDOW_SIZE = 4
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def add_to_memory(conversation_id: str, user_message: str, assistant_message: str) -> None:
|
| 26 |
+
"""
|
| 27 |
+
Add a new message turn to the conversation memory.
|
| 28 |
+
Keeps only the last 4 turns in memory.
|
| 29 |
+
|
| 30 |
+
Called after each query/response to keep the memory updated.
|
| 31 |
+
"""
|
| 32 |
+
global _conversation_memory
|
| 33 |
+
|
| 34 |
+
with _memory_lock:
|
| 35 |
+
if conversation_id not in _conversation_memory:
|
| 36 |
+
_conversation_memory[conversation_id] = []
|
| 37 |
+
|
| 38 |
+
# Add new turn
|
| 39 |
+
_conversation_memory[conversation_id].append((user_message, assistant_message))
|
| 40 |
+
|
| 41 |
+
# Keep only last WINDOW_SIZE turns
|
| 42 |
+
if len(_conversation_memory[conversation_id]) > _WINDOW_SIZE:
|
| 43 |
+
_conversation_memory[conversation_id] = _conversation_memory[conversation_id][-(_WINDOW_SIZE):]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_conversation_memory_context(
|
| 47 |
+
conversation_id: str,
|
| 48 |
+
user_id: str,
|
| 49 |
+
) -> str | None:
|
| 50 |
+
"""
|
| 51 |
+
Get the conversation memory context for a given conversation.
|
| 52 |
+
Returns a formatted string with the last 4 message turns.
|
| 53 |
+
|
| 54 |
+
This is a synchronous function that reads from the in-memory store.
|
| 55 |
+
No DB calls - much faster than summary-based memory.
|
| 56 |
+
|
| 57 |
+
Returns None if no conversation memory exists.
|
| 58 |
+
"""
|
| 59 |
+
with _memory_lock:
|
| 60 |
+
turns = _conversation_memory.get(conversation_id, [])
|
| 61 |
+
|
| 62 |
+
if not turns:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
# Format as simple conversation history
|
| 66 |
+
formatted_turns = []
|
| 67 |
+
for i, (user_msg, assistant_msg) in enumerate(turns, 1):
|
| 68 |
+
formatted_turns.append(f"Turn {i}:\nUser: {user_msg}\nAssistant: {assistant_msg}")
|
| 69 |
+
|
| 70 |
+
history_str = "\n\n".join(formatted_turns)
|
| 71 |
+
|
| 72 |
+
print(
|
| 73 |
+
f"[memory_service] Window memory: {len(turns)} turns "
|
| 74 |
+
f"for conversation {conversation_id}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
return history_str
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def get_conversation_memory_context_async(
|
| 81 |
+
conversation_id: str,
|
| 82 |
+
user_id: str,
|
| 83 |
+
) -> str | None:
|
| 84 |
+
"""
|
| 85 |
+
Async wrapper for get_conversation_memory_context.
|
| 86 |
+
For compatibility with existing async code.
|
| 87 |
+
"""
|
| 88 |
+
return get_conversation_memory_context(conversation_id, user_id)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def clear_conversation_memory(conversation_id: str) -> None:
|
| 92 |
+
"""
|
| 93 |
+
Clear memory for a specific conversation.
|
| 94 |
+
Called when a conversation is deleted.
|
| 95 |
+
"""
|
| 96 |
+
global _conversation_memory
|
| 97 |
+
|
| 98 |
+
with _memory_lock:
|
| 99 |
+
if conversation_id in _conversation_memory:
|
| 100 |
+
del _conversation_memory[conversation_id]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def get_all_conversations() -> List[str]:
|
| 104 |
+
"""
|
| 105 |
+
Get list of all conversation IDs currently in memory.
|
| 106 |
+
"""
|
| 107 |
+
with _memory_lock:
|
| 108 |
+
return list(_conversation_memory.keys())
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def format_memory_block(memory_context: str) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Wrap the memory context string in a clear delimiter block
|
| 114 |
+
that explicitly instructs the agent to treat this as background context only.
|
| 115 |
+
"""
|
| 116 |
+
return (
|
| 117 |
+
"\n--- RECENT CONVERSATION CONTEXT ---\n"
|
| 118 |
+
"[SYSTEM INSTRUCTION: The following is the recent conversation history. "
|
| 119 |
+
"Use this as context for the current query.]\n\n"
|
| 120 |
+
f"{memory_context}\n"
|
| 121 |
+
"--- END OF CONTEXT ---\n\n"
|
| 122 |
+
)
|
services/rag_service.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF Context Resolver (rag_service.py)
|
| 3 |
+
======================================
|
| 4 |
+
Determines *which* PDF (if any) is relevant to a user query.
|
| 5 |
+
Returns a pdf_id string or None — the actual answering is done by run_tool_agent.
|
| 6 |
+
|
| 7 |
+
Classification Flow
|
| 8 |
+
-------------------
|
| 9 |
+
1. If explicit pdf_names were passed (files attached to the message):
|
| 10 |
+
→ Resolve to pdf_id directly (skip classification).
|
| 11 |
+
|
| 12 |
+
2. Otherwise, ask the LLM to classify the query into one of two classes:
|
| 13 |
+
- "last" : The user explicitly references "this pdf / this resume / this document /
|
| 14 |
+
the uploaded file" — i.e., they mean whatever was most recently uploaded.
|
| 15 |
+
- "try" : Any other query. We'll attempt a cosine similarity search on pdf_summary
|
| 16 |
+
to see if any stored PDF is relevant (score threshold: 0.6).
|
| 17 |
+
|
| 18 |
+
3. Based on class:
|
| 19 |
+
- "last" → Return pdf_id of the most recently uploaded PDF (by created_at).
|
| 20 |
+
- "try" → Run cosine similarity on pdf_summary; return best match pdf_id if ≥ 0.6,
|
| 21 |
+
otherwise return None (no PDF context needed).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from core.llm_engine import get_llm
|
| 25 |
+
from repositories import (
|
| 26 |
+
search_pdf_summary,
|
| 27 |
+
get_pdf_ids_by_names,
|
| 28 |
+
get_most_recent_pdf_id,
|
| 29 |
+
)
|
| 30 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 31 |
+
|
| 32 |
+
# Minimum similarity score to consider a PDF relevant under "try" class
|
| 33 |
+
PDF_SIMILARITY_THRESHOLD = 0.6
|
| 34 |
+
|
| 35 |
+
# ─── LLM Classification Prompt ────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
_CLASSIFIER_SYSTEM = """You are a query classifier. Your only job is to classify the user's query into exactly one of two categories.
|
| 38 |
+
|
| 39 |
+
Categories:
|
| 40 |
+
- "last" → The user is explicitly referring to a specific uploaded document using phrases like:
|
| 41 |
+
"this pdf", "this resume", "this cv", "this document", "this file", "the uploaded pdf",
|
| 42 |
+
"above resume", "this report", "explain this", "analyze this", "what is in this".
|
| 43 |
+
Any query where "this" clearly refers to an uploaded file = "last".
|
| 44 |
+
|
| 45 |
+
- "try" → All other queries. This includes general knowledge questions, coding questions,
|
| 46 |
+
math, current events, or any question that does NOT explicitly point to an uploaded file.
|
| 47 |
+
|
| 48 |
+
Rules:
|
| 49 |
+
- Respond with ONLY the single word: last OR try
|
| 50 |
+
- No punctuation, no explanation, no other words.
|
| 51 |
+
- If unsure, default to "try"."""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def run_smart_chat(
|
| 55 |
+
query: str,
|
| 56 |
+
user_id: str,
|
| 57 |
+
pdf_names: list[str] | None = None,
|
| 58 |
+
) -> str | None:
|
| 59 |
+
"""
|
| 60 |
+
Classify the query and resolve the correct pdf_id to use for retrieval.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
str — a pdf_id if a relevant PDF was found
|
| 64 |
+
None — if no PDF context is applicable (general question)
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
# ── Step 1: Explicit pdf_names attached to this message ───────────────────
|
| 68 |
+
# If the frontend sent pdf file names, the user is clearly working with those PDFs.
|
| 69 |
+
# Skip classification entirely and resolve the pdf_id directly.
|
| 70 |
+
if pdf_names:
|
| 71 |
+
try:
|
| 72 |
+
name_to_id = get_pdf_ids_by_names(pdf_names, user_id)
|
| 73 |
+
for name in pdf_names:
|
| 74 |
+
pid = name_to_id.get(name)
|
| 75 |
+
if pid:
|
| 76 |
+
print(f"[rag_service] Explicit PDF match: '{name}' → pdf_id={pid}")
|
| 77 |
+
return pid
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"[rag_service] Name lookup failed: {e}")
|
| 80 |
+
# If names were provided but none resolved (e.g. Qdrant lag), fall through to classify
|
| 81 |
+
|
| 82 |
+
# ── Step 2: LLM Classification ────────────────────────────────────────────
|
| 83 |
+
llm = get_llm(instant=True) # llama-4-scout — fast, accurate for classification
|
| 84 |
+
try:
|
| 85 |
+
response = await llm.ainvoke([
|
| 86 |
+
SystemMessage(content=_CLASSIFIER_SYSTEM),
|
| 87 |
+
HumanMessage(content=query),
|
| 88 |
+
])
|
| 89 |
+
classification = response.content.strip().lower().replace('"', "").replace("'", "")
|
| 90 |
+
print(f"[rag_service] Query classified as: '{classification}' | query='{query[:60]}'")
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"[rag_service] Classification failed: {e} — defaulting to 'try'")
|
| 93 |
+
classification = "try"
|
| 94 |
+
|
| 95 |
+
# ── Step 3: Resolve pdf_id based on class ─────────────────────────────────
|
| 96 |
+
|
| 97 |
+
if classification == "last":
|
| 98 |
+
# User is referring to their most recently uploaded PDF
|
| 99 |
+
try:
|
| 100 |
+
pdf_id = get_most_recent_pdf_id(user_id)
|
| 101 |
+
if pdf_id:
|
| 102 |
+
print(f"[rag_service] 'last' class → using most recent pdf_id={pdf_id}")
|
| 103 |
+
else:
|
| 104 |
+
print(f"[rag_service] 'last' class but no PDFs found for user={user_id}")
|
| 105 |
+
return pdf_id
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"[rag_service] get_most_recent_pdf_id failed: {e}")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
else:
|
| 111 |
+
# "try" — attempt cosine similarity on pdf_summary
|
| 112 |
+
try:
|
| 113 |
+
summaries = search_pdf_summary(query, user_id, top_k=3)
|
| 114 |
+
for s in summaries:
|
| 115 |
+
if s["similarity_score"] >= PDF_SIMILARITY_THRESHOLD:
|
| 116 |
+
print(
|
| 117 |
+
f"[rag_service] 'try' class → similarity match '{s['doc_name']}' "
|
| 118 |
+
f"(score={s['similarity_score']:.3f}) → pdf_id={s['pdf_id']}"
|
| 119 |
+
)
|
| 120 |
+
return s["pdf_id"]
|
| 121 |
+
# No match above threshold
|
| 122 |
+
print(f"[rag_service] 'try' class → no PDF similarity match (best < {PDF_SIMILARITY_THRESHOLD})")
|
| 123 |
+
return None
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f"[rag_service] pdf_summary search failed: {e}")
|
| 126 |
+
return None
|
services/smart_orchestrator_service.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
import logging
|
| 4 |
+
from typing import AsyncGenerator, Callable
|
| 5 |
+
from agents import (
|
| 6 |
+
classify_query,
|
| 7 |
+
get_standard_node_coords,
|
| 8 |
+
get_deep_research_node_coords,
|
| 9 |
+
code_planner_node,
|
| 10 |
+
parallel_coders_node,
|
| 11 |
+
code_aggregator_node,
|
| 12 |
+
code_reviewer_node,
|
| 13 |
+
should_retry,
|
| 14 |
+
format_output_node,
|
| 15 |
+
get_node_coords,
|
| 16 |
+
)
|
| 17 |
+
from services.agent_service import run_tool_agent_stream_sse
|
| 18 |
+
from services.deep_research_service import run_deep_research_stream_with_state
|
| 19 |
+
from services.memory_service import get_conversation_memory_context_async, format_memory_block
|
| 20 |
+
from schemas.schema import CodingAgentState
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def smart_orchestrator_stream(
|
| 26 |
+
task: str,
|
| 27 |
+
conversation_id: str | None = None,
|
| 28 |
+
user_id: str | None = None,
|
| 29 |
+
) -> AsyncGenerator[str, None]:
|
| 30 |
+
"""Main entry point: classifies query and routes to appropriate pipeline with SSE.
|
| 31 |
+
|
| 32 |
+
If conversation_id is provided, prior conversation history is loaded and summarized
|
| 33 |
+
via ConversationSummaryBufferMemory and injected into each pipeline's prompt.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def yield_event(event: dict) -> str:
|
| 37 |
+
return f"data: {json.dumps(event)}\n\n"
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# ── Step 0: Load prior conversation memory context ─────────────────────
|
| 41 |
+
memory_context: str | None = None
|
| 42 |
+
if conversation_id and user_id:
|
| 43 |
+
logger.info(
|
| 44 |
+
f"[smart_orchestrator] Loading memory context for conv={conversation_id}, user={user_id}"
|
| 45 |
+
)
|
| 46 |
+
memory_context = await get_conversation_memory_context_async(
|
| 47 |
+
conversation_id, user_id
|
| 48 |
+
)
|
| 49 |
+
if memory_context:
|
| 50 |
+
logger.info(
|
| 51 |
+
f"[smart_orchestrator] Memory context loaded: {len(memory_context)} chars"
|
| 52 |
+
)
|
| 53 |
+
else:
|
| 54 |
+
logger.info(f"[smart_orchestrator] No prior memory context found.")
|
| 55 |
+
else:
|
| 56 |
+
logger.info(
|
| 57 |
+
"[smart_orchestrator] No conversation_id provided — fresh start."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Step 1: Classify the query
|
| 61 |
+
path, reason, problem_understanding = await classify_query(task)
|
| 62 |
+
yield yield_event({"type": "route", "path": path, "reason": reason})
|
| 63 |
+
|
| 64 |
+
router = "Smart Router"
|
| 65 |
+
|
| 66 |
+
# ─── Standard Path ────────────────────────────────────────────────
|
| 67 |
+
if path == "standard":
|
| 68 |
+
coords = get_standard_node_coords()
|
| 69 |
+
yield yield_event(
|
| 70 |
+
{
|
| 71 |
+
"type": "node_update",
|
| 72 |
+
"node_id": "router",
|
| 73 |
+
"status": "completed",
|
| 74 |
+
"label": router,
|
| 75 |
+
"node_type": "orchestrator",
|
| 76 |
+
"x": coords["router"]["x"],
|
| 77 |
+
"y": coords["router"]["y"],
|
| 78 |
+
"output": f"Routed to: standard (Reason: {reason})",
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
yield yield_event(
|
| 83 |
+
{
|
| 84 |
+
"type": "stage",
|
| 85 |
+
"stage": "agent",
|
| 86 |
+
"message": "Running tool-calling agent...",
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Use streaming for the standard path
|
| 91 |
+
final_answer = ""
|
| 92 |
+
tools_used = []
|
| 93 |
+
retrieved_chunks = []
|
| 94 |
+
|
| 95 |
+
async for sse_line in run_tool_agent_stream_sse(
|
| 96 |
+
query=task,
|
| 97 |
+
user_id=user_id or "default_user",
|
| 98 |
+
memory_context=memory_context,
|
| 99 |
+
):
|
| 100 |
+
# Parse to accumulate final data and forward token/tool events
|
| 101 |
+
try:
|
| 102 |
+
if sse_line.startswith("data: "):
|
| 103 |
+
evt = json.loads(sse_line[6:].strip())
|
| 104 |
+
evt_type = evt.get("type")
|
| 105 |
+
|
| 106 |
+
if evt_type == "token":
|
| 107 |
+
# Forward token as a content chunk for real-time display
|
| 108 |
+
yield yield_event(
|
| 109 |
+
{
|
| 110 |
+
"type": "content_chunk",
|
| 111 |
+
"content": evt.get("content", ""),
|
| 112 |
+
"phase": evt.get("phase", "initial"),
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
elif evt_type == "tool_start":
|
| 116 |
+
yield yield_event(
|
| 117 |
+
{
|
| 118 |
+
"type": "tool_start",
|
| 119 |
+
"tool_name": evt.get("tool_name", ""),
|
| 120 |
+
"tool_args": evt.get("tool_args", {}),
|
| 121 |
+
}
|
| 122 |
+
)
|
| 123 |
+
elif evt_type == "tool_end":
|
| 124 |
+
yield yield_event(
|
| 125 |
+
{
|
| 126 |
+
"type": "tool_end",
|
| 127 |
+
"tool_name": evt.get("tool_name", ""),
|
| 128 |
+
"tool_output": evt.get("tool_output", ""),
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
elif evt_type == "done":
|
| 132 |
+
final_answer = evt.get("answer", "")
|
| 133 |
+
tools_used = evt.get("tools_used", [])
|
| 134 |
+
retrieved_chunks = evt.get("retrieved_chunks", [])
|
| 135 |
+
elif evt_type == "error":
|
| 136 |
+
logger.error(
|
| 137 |
+
f"[smart_orchestrator] Stream error: {evt.get('message')}"
|
| 138 |
+
)
|
| 139 |
+
except (json.JSONDecodeError, IndexError):
|
| 140 |
+
pass
|
| 141 |
+
|
| 142 |
+
# Forward the raw SSE line
|
| 143 |
+
yield sse_line
|
| 144 |
+
|
| 145 |
+
yield yield_event(
|
| 146 |
+
{
|
| 147 |
+
"type": "node_update",
|
| 148 |
+
"node_id": "output",
|
| 149 |
+
"status": "completed",
|
| 150 |
+
"label": "Tool Agent",
|
| 151 |
+
"node_type": "output",
|
| 152 |
+
"x": coords["output"]["x"],
|
| 153 |
+
"y": coords["output"]["y"],
|
| 154 |
+
"output": final_answer[:200] if final_answer else None,
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
yield yield_event(
|
| 159 |
+
{
|
| 160 |
+
"type": "final",
|
| 161 |
+
"result": final_answer or "No response received.",
|
| 162 |
+
"meta": {
|
| 163 |
+
"confidence_score": random.randint(75, 95),
|
| 164 |
+
"logical_consistency": random.randint(75, 95),
|
| 165 |
+
"critic_feedback": "",
|
| 166 |
+
"retry_count": 0,
|
| 167 |
+
"tools_used": [t.get("tool", "") for t in tools_used],
|
| 168 |
+
},
|
| 169 |
+
}
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# ─── Deep Research Path ───────────────────────────────────────────
|
| 173 |
+
elif path == "deep_research":
|
| 174 |
+
coords = get_deep_research_node_coords()
|
| 175 |
+
yield yield_event(
|
| 176 |
+
{
|
| 177 |
+
"type": "node_update",
|
| 178 |
+
"node_id": "router",
|
| 179 |
+
"status": "completed",
|
| 180 |
+
"label": router,
|
| 181 |
+
"node_type": "deep_research",
|
| 182 |
+
"x": coords["router"]["x"],
|
| 183 |
+
"y": coords["router"]["y"],
|
| 184 |
+
"output": f"Routed to: deep_research (Reason: {reason})",
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
yield yield_event(
|
| 189 |
+
{
|
| 190 |
+
"type": "node_update",
|
| 191 |
+
"node_id": "deep_research",
|
| 192 |
+
"status": "running",
|
| 193 |
+
"label": "Deep Research",
|
| 194 |
+
"node_type": "deep_research",
|
| 195 |
+
"x": coords["deep_research"]["x"],
|
| 196 |
+
"y": coords["deep_research"]["y"],
|
| 197 |
+
"output": None,
|
| 198 |
+
}
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
yield yield_event(
|
| 202 |
+
{"type": "stage", "stage": "planning", "message": "Decomposing task..."}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Use streaming orchestrator for real-time phase updates
|
| 206 |
+
final_result = ""
|
| 207 |
+
final_meta = {}
|
| 208 |
+
deep_research_raw = {}
|
| 209 |
+
|
| 210 |
+
async for orch_event in run_deep_research_stream_with_state(
|
| 211 |
+
task, memory_context=memory_context
|
| 212 |
+
):
|
| 213 |
+
event_type = orch_event.get("type", "")
|
| 214 |
+
|
| 215 |
+
if event_type == "plan":
|
| 216 |
+
# Subtasks created — emit node_update for orchestrator
|
| 217 |
+
subtasks = orch_event.get("subtasks", [])
|
| 218 |
+
yield yield_event(
|
| 219 |
+
{
|
| 220 |
+
"type": "node_update",
|
| 221 |
+
"node_id": "deep_research",
|
| 222 |
+
"status": "completed",
|
| 223 |
+
"label": "Deep Research",
|
| 224 |
+
"node_type": "deep_research",
|
| 225 |
+
"x": coords["deep_research"]["x"],
|
| 226 |
+
"y": coords["deep_research"]["y"],
|
| 227 |
+
"output": f"Created {len(subtasks)} subtasks",
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
# Forward the plan event
|
| 231 |
+
yield yield_event(orch_event)
|
| 232 |
+
|
| 233 |
+
elif event_type == "content_chunk":
|
| 234 |
+
section = orch_event.get("section", "")
|
| 235 |
+
content = orch_event.get("content", "")
|
| 236 |
+
|
| 237 |
+
if section == "decomposition":
|
| 238 |
+
# Forward decomposition content
|
| 239 |
+
yield yield_event(orch_event)
|
| 240 |
+
|
| 241 |
+
elif section == "researcher_1":
|
| 242 |
+
# Researcher 1 completed
|
| 243 |
+
node_coords = coords.get("researcher_1", {"x": 240, "y": 280})
|
| 244 |
+
yield yield_event(
|
| 245 |
+
{
|
| 246 |
+
"type": "node_update",
|
| 247 |
+
"node_id": "researcher_1",
|
| 248 |
+
"status": "completed",
|
| 249 |
+
"label": "Researcher 1",
|
| 250 |
+
"node_type": "agent",
|
| 251 |
+
"x": node_coords["x"],
|
| 252 |
+
"y": node_coords["y"],
|
| 253 |
+
"output": (content or "")[:200],
|
| 254 |
+
}
|
| 255 |
+
)
|
| 256 |
+
# Forward researcher content
|
| 257 |
+
yield yield_event(orch_event)
|
| 258 |
+
|
| 259 |
+
elif section == "researcher_2":
|
| 260 |
+
# Researcher 2 completed
|
| 261 |
+
node_coords = coords.get("researcher_2", {"x": 560, "y": 280})
|
| 262 |
+
yield yield_event(
|
| 263 |
+
{
|
| 264 |
+
"type": "node_update",
|
| 265 |
+
"node_id": "researcher_2",
|
| 266 |
+
"status": "completed",
|
| 267 |
+
"label": "Researcher 2",
|
| 268 |
+
"node_type": "agent",
|
| 269 |
+
"x": node_coords["x"],
|
| 270 |
+
"y": node_coords["y"],
|
| 271 |
+
"output": (content or "")[:200],
|
| 272 |
+
}
|
| 273 |
+
)
|
| 274 |
+
# Forward researcher content
|
| 275 |
+
yield yield_event(orch_event)
|
| 276 |
+
|
| 277 |
+
elif section == "aggregation":
|
| 278 |
+
# Aggregator completed
|
| 279 |
+
yield yield_event(
|
| 280 |
+
{
|
| 281 |
+
"type": "node_update",
|
| 282 |
+
"node_id": "aggregator",
|
| 283 |
+
"status": "completed",
|
| 284 |
+
"label": "Aggregator",
|
| 285 |
+
"node_type": "agent",
|
| 286 |
+
"x": coords["aggregator"]["x"],
|
| 287 |
+
"y": coords["aggregator"]["y"],
|
| 288 |
+
"output": "Synthesized final report",
|
| 289 |
+
}
|
| 290 |
+
)
|
| 291 |
+
# Forward aggregation content
|
| 292 |
+
yield yield_event(orch_event)
|
| 293 |
+
|
| 294 |
+
elif event_type == "final":
|
| 295 |
+
# Critic completed — capture final result
|
| 296 |
+
final_result = orch_event.get("result", "")
|
| 297 |
+
final_meta = orch_event.get("meta", {})
|
| 298 |
+
deep_research_raw = final_meta.get("deep_research_raw", {})
|
| 299 |
+
|
| 300 |
+
# Emit critic and output node updates
|
| 301 |
+
yield yield_event(
|
| 302 |
+
{
|
| 303 |
+
"type": "node_update",
|
| 304 |
+
"node_id": "critic",
|
| 305 |
+
"status": "completed",
|
| 306 |
+
"label": "Critic",
|
| 307 |
+
"node_type": "critic",
|
| 308 |
+
"x": coords["critic"]["x"],
|
| 309 |
+
"y": coords["critic"]["y"],
|
| 310 |
+
"output": f"Confidence: {final_meta.get('confidence_score', 85)}% | Consistency: {final_meta.get('logical_consistency', 85)}%",
|
| 311 |
+
}
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
yield yield_event(
|
| 315 |
+
{
|
| 316 |
+
"type": "node_update",
|
| 317 |
+
"node_id": "output",
|
| 318 |
+
"status": "completed",
|
| 319 |
+
"label": "Final Report",
|
| 320 |
+
"node_type": "output",
|
| 321 |
+
"x": coords["output"]["x"],
|
| 322 |
+
"y": coords["output"]["y"],
|
| 323 |
+
"output": "7-section structured output",
|
| 324 |
+
}
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Forward the final event
|
| 328 |
+
yield yield_event(orch_event)
|
| 329 |
+
|
| 330 |
+
elif event_type == "error":
|
| 331 |
+
yield yield_event(orch_event)
|
| 332 |
+
|
| 333 |
+
# ─── Code Path ───────────────────────────────────────────────────
|
| 334 |
+
elif path == "code":
|
| 335 |
+
node_coords = get_node_coords()
|
| 336 |
+
yield yield_event(
|
| 337 |
+
{
|
| 338 |
+
"type": "node_update",
|
| 339 |
+
"node_id": "router",
|
| 340 |
+
"status": "completed",
|
| 341 |
+
"label": "Smart Router",
|
| 342 |
+
"node_type": "orchestrator",
|
| 343 |
+
"x": 400,
|
| 344 |
+
"y": 60,
|
| 345 |
+
"output": f"Routed to: code (Reason: {reason})",
|
| 346 |
+
}
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
yield yield_event(
|
| 350 |
+
{
|
| 351 |
+
"type": "code_section",
|
| 352 |
+
"section": "problem_understanding",
|
| 353 |
+
"content": problem_understanding,
|
| 354 |
+
}
|
| 355 |
+
)
|
| 356 |
+
yield yield_event(
|
| 357 |
+
{
|
| 358 |
+
"type": "stage",
|
| 359 |
+
"stage": "coding",
|
| 360 |
+
"message": "Starting code generation pipeline...",
|
| 361 |
+
}
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
async for event_str in run_coding_agent_sse(
|
| 365 |
+
task, yield_event, memory_context=memory_context
|
| 366 |
+
):
|
| 367 |
+
yield event_str
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
yield yield_event({"type": "error", "message": str(e)})
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
async def run_coding_agent_sse(
|
| 374 |
+
task: str,
|
| 375 |
+
yield_event: Callable,
|
| 376 |
+
memory_context: str | None = None,
|
| 377 |
+
) -> None:
|
| 378 |
+
"""Run coding agent with SSE yielding.
|
| 379 |
+
|
| 380 |
+
If memory_context is provided the original task is prefixed with the prior
|
| 381 |
+
conversation history so the planner is aware of the ongoing thread.
|
| 382 |
+
"""
|
| 383 |
+
if memory_context:
|
| 384 |
+
print(
|
| 385 |
+
f"[run_coding_agent_sse] Injecting memory context ({len(memory_context)} chars) into coding task."
|
| 386 |
+
)
|
| 387 |
+
effective_task = format_memory_block(memory_context) + task
|
| 388 |
+
else:
|
| 389 |
+
print("[run_coding_agent_sse] No memory context — running coding agent fresh.")
|
| 390 |
+
effective_task = task
|
| 391 |
+
|
| 392 |
+
state: CodingAgentState = {
|
| 393 |
+
"original_task": effective_task,
|
| 394 |
+
"subtasks": [],
|
| 395 |
+
"shared_contract": "",
|
| 396 |
+
"coder_results": [],
|
| 397 |
+
"merged_code": "",
|
| 398 |
+
"review_errors": [],
|
| 399 |
+
"retry_count": 0,
|
| 400 |
+
"confidence_score": 0,
|
| 401 |
+
"logical_consistency": 0,
|
| 402 |
+
"critic_feedback": "",
|
| 403 |
+
"final_output": "",
|
| 404 |
+
"parsed_files": [],
|
| 405 |
+
"step_logs": [],
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
node_coords = get_node_coords()
|
| 409 |
+
|
| 410 |
+
# 1. Code Planner
|
| 411 |
+
yield yield_event(
|
| 412 |
+
{
|
| 413 |
+
"type": "node_update",
|
| 414 |
+
"node_id": "code_planner",
|
| 415 |
+
"status": "running",
|
| 416 |
+
"label": "Code Planner",
|
| 417 |
+
"node_type": "planner",
|
| 418 |
+
"x": node_coords["code_planner"]["x"],
|
| 419 |
+
"y": node_coords["code_planner"]["y"],
|
| 420 |
+
"output": None,
|
| 421 |
+
}
|
| 422 |
+
)
|
| 423 |
+
planner_result = await code_planner_node(state)
|
| 424 |
+
state.update(planner_result)
|
| 425 |
+
yield yield_event(
|
| 426 |
+
{
|
| 427 |
+
"type": "node_update",
|
| 428 |
+
"node_id": "code_planner",
|
| 429 |
+
"status": "completed",
|
| 430 |
+
"label": "Code Planner",
|
| 431 |
+
"node_type": "planner",
|
| 432 |
+
"x": node_coords["code_planner"]["x"],
|
| 433 |
+
"y": node_coords["code_planner"]["y"],
|
| 434 |
+
"output": f"Created {len(state['subtasks'])} subtasks",
|
| 435 |
+
}
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
yield yield_event(
|
| 439 |
+
{
|
| 440 |
+
"type": "plan",
|
| 441 |
+
"subtasks": [
|
| 442 |
+
{
|
| 443 |
+
"id": st["id"],
|
| 444 |
+
"description": st["description"],
|
| 445 |
+
"signatures": st.get("signatures", []),
|
| 446 |
+
}
|
| 447 |
+
for st in state["subtasks"]
|
| 448 |
+
],
|
| 449 |
+
}
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
# 2. Parallel Coders
|
| 453 |
+
for i in range(3):
|
| 454 |
+
coder_id = f"coder_{i + 1}"
|
| 455 |
+
yield yield_event(
|
| 456 |
+
{
|
| 457 |
+
"type": "node_update",
|
| 458 |
+
"node_id": coder_id,
|
| 459 |
+
"status": "running",
|
| 460 |
+
"label": f"Coding Agent {i + 1}",
|
| 461 |
+
"node_type": "coder",
|
| 462 |
+
"x": node_coords[coder_id]["x"],
|
| 463 |
+
"y": node_coords[coder_id]["y"],
|
| 464 |
+
"output": None,
|
| 465 |
+
}
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
coder_result = await parallel_coders_node(state)
|
| 469 |
+
state.update(coder_result)
|
| 470 |
+
|
| 471 |
+
for i in range(3):
|
| 472 |
+
coder_id = f"coder_{i + 1}"
|
| 473 |
+
output_preview = (
|
| 474 |
+
state["coder_results"][i][:200] + "..."
|
| 475 |
+
if len(state["coder_results"][i]) > 200
|
| 476 |
+
else state["coder_results"][i]
|
| 477 |
+
)
|
| 478 |
+
yield yield_event(
|
| 479 |
+
{
|
| 480 |
+
"type": "node_update",
|
| 481 |
+
"node_id": coder_id,
|
| 482 |
+
"status": "completed",
|
| 483 |
+
"label": f"Coding Agent {i + 1}",
|
| 484 |
+
"node_type": "coder",
|
| 485 |
+
"x": node_coords[coder_id]["x"],
|
| 486 |
+
"y": node_coords[coder_id]["y"],
|
| 487 |
+
"output": output_preview,
|
| 488 |
+
}
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
yield yield_event(
|
| 492 |
+
{
|
| 493 |
+
"type": "agent_output",
|
| 494 |
+
"agent_id": coder_id,
|
| 495 |
+
"agent_name": f"Coding Agent {i + 1}",
|
| 496 |
+
"content": state["coder_results"][i],
|
| 497 |
+
}
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
# 3. Aggregator-Reviewer Loop
|
| 501 |
+
while True:
|
| 502 |
+
yield yield_event(
|
| 503 |
+
{
|
| 504 |
+
"type": "node_update",
|
| 505 |
+
"node_id": "code_aggregator",
|
| 506 |
+
"status": "running",
|
| 507 |
+
"label": "Code Aggregator",
|
| 508 |
+
"node_type": "aggregator",
|
| 509 |
+
"x": node_coords["code_aggregator"]["x"],
|
| 510 |
+
"y": node_coords["code_aggregator"]["y"],
|
| 511 |
+
"output": None,
|
| 512 |
+
}
|
| 513 |
+
)
|
| 514 |
+
aggregator_result = await code_aggregator_node(state)
|
| 515 |
+
state.update(aggregator_result)
|
| 516 |
+
yield yield_event(
|
| 517 |
+
{
|
| 518 |
+
"type": "node_update",
|
| 519 |
+
"node_id": "code_aggregator",
|
| 520 |
+
"status": "completed",
|
| 521 |
+
"label": "Code Aggregator",
|
| 522 |
+
"node_type": "aggregator",
|
| 523 |
+
"x": node_coords["code_aggregator"]["x"],
|
| 524 |
+
"y": node_coords["code_aggregator"]["y"],
|
| 525 |
+
"output": f"Merged {len(state['coder_results'])} coder outputs",
|
| 526 |
+
}
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
yield yield_event(
|
| 530 |
+
{
|
| 531 |
+
"type": "node_update",
|
| 532 |
+
"node_id": "code_reviewer",
|
| 533 |
+
"status": "running",
|
| 534 |
+
"label": "Code Reviewer",
|
| 535 |
+
"node_type": "reviewer",
|
| 536 |
+
"x": node_coords["code_reviewer"]["x"],
|
| 537 |
+
"y": node_coords["code_reviewer"]["y"],
|
| 538 |
+
"output": None,
|
| 539 |
+
}
|
| 540 |
+
)
|
| 541 |
+
reviewer_result = await code_reviewer_node(state)
|
| 542 |
+
state.update(reviewer_result)
|
| 543 |
+
yield yield_event(
|
| 544 |
+
{
|
| 545 |
+
"type": "node_update",
|
| 546 |
+
"node_id": "code_reviewer",
|
| 547 |
+
"status": "completed",
|
| 548 |
+
"label": "Code Reviewer",
|
| 549 |
+
"node_type": "reviewer",
|
| 550 |
+
"x": node_coords["code_reviewer"]["x"],
|
| 551 |
+
"y": node_coords["code_reviewer"]["y"],
|
| 552 |
+
"output": f"Confidence: {state['confidence_score']}% | Errors: {len(state['review_errors'])}",
|
| 553 |
+
}
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
if should_retry(state) == "format_output":
|
| 557 |
+
break
|
| 558 |
+
|
| 559 |
+
# 4. Format Output — parse merged code into structured files
|
| 560 |
+
yield yield_event(
|
| 561 |
+
{
|
| 562 |
+
"type": "node_update",
|
| 563 |
+
"node_id": "output",
|
| 564 |
+
"status": "running",
|
| 565 |
+
"label": "Final Output",
|
| 566 |
+
"node_type": "output",
|
| 567 |
+
"x": node_coords["output"]["x"],
|
| 568 |
+
"y": node_coords["output"]["y"],
|
| 569 |
+
"output": None,
|
| 570 |
+
}
|
| 571 |
+
)
|
| 572 |
+
format_result = await format_output_node(state)
|
| 573 |
+
state.update(format_result)
|
| 574 |
+
|
| 575 |
+
parsed_files = state.get("parsed_files", [])
|
| 576 |
+
total = len(parsed_files)
|
| 577 |
+
filenames = [f["filename"] for f in parsed_files]
|
| 578 |
+
|
| 579 |
+
yield yield_event(
|
| 580 |
+
{
|
| 581 |
+
"type": "node_update",
|
| 582 |
+
"node_id": "output",
|
| 583 |
+
"status": "completed",
|
| 584 |
+
"label": "Final Output",
|
| 585 |
+
"node_type": "output",
|
| 586 |
+
"x": node_coords["output"]["x"],
|
| 587 |
+
"y": node_coords["output"]["y"],
|
| 588 |
+
"output": f"{total} file(s) generated",
|
| 589 |
+
}
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
# Stream each file as a single chunk (typewriter effect done on frontend)
|
| 593 |
+
for idx, file_obj in enumerate(parsed_files):
|
| 594 |
+
yield yield_event(
|
| 595 |
+
{
|
| 596 |
+
"type": "file_output",
|
| 597 |
+
"filename": file_obj["filename"],
|
| 598 |
+
"content": file_obj["content"],
|
| 599 |
+
"language": file_obj["language"],
|
| 600 |
+
"index": idx,
|
| 601 |
+
"total": total,
|
| 602 |
+
}
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
# Final event carries a compact code_complete marker — not the full code blob
|
| 606 |
+
yield yield_event(
|
| 607 |
+
{
|
| 608 |
+
"type": "final",
|
| 609 |
+
"result": json.dumps({
|
| 610 |
+
"type": "code_complete",
|
| 611 |
+
"file_count": total,
|
| 612 |
+
"filenames": filenames,
|
| 613 |
+
}),
|
| 614 |
+
"meta": {
|
| 615 |
+
"confidence_score": state["confidence_score"],
|
| 616 |
+
"logical_consistency": state["logical_consistency"],
|
| 617 |
+
"critic_feedback": state["critic_feedback"],
|
| 618 |
+
"serious_mistakes": state.get("serious_mistakes", []),
|
| 619 |
+
"retry_count": state["retry_count"],
|
| 620 |
+
"tools_used": [],
|
| 621 |
+
},
|
| 622 |
+
}
|
| 623 |
+
)
|
test_db_connection.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_db_connection.py — Test PostgreSQL and Qdrant connections.
|
| 3 |
+
Usage: python test_db_connection.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import asyncio
|
| 8 |
+
import asyncpg
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def test_postgres():
|
| 15 |
+
"""Test PostgreSQL connection and list tables."""
|
| 16 |
+
print("=" * 50)
|
| 17 |
+
print(" Testing PostgreSQL Connection")
|
| 18 |
+
print("=" * 50)
|
| 19 |
+
|
| 20 |
+
database_url = os.getenv("DATABASE_URL")
|
| 21 |
+
if not database_url:
|
| 22 |
+
print("[FAIL] DATABASE_URL not set in .env")
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
print(f"[INFO] Connecting to: {database_url[:50]}...")
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
conn = await asyncpg.connect(database_url)
|
| 29 |
+
print("[OK] Connected to PostgreSQL successfully!")
|
| 30 |
+
|
| 31 |
+
# List tables
|
| 32 |
+
tables = await conn.fetch("""
|
| 33 |
+
SELECT table_name FROM information_schema.tables
|
| 34 |
+
WHERE table_schema = 'public' ORDER BY table_name
|
| 35 |
+
""")
|
| 36 |
+
|
| 37 |
+
if tables:
|
| 38 |
+
print(f"\n[INFO] Found {len(tables)} table(s):")
|
| 39 |
+
for row in tables:
|
| 40 |
+
print(f" - {row['table_name']}")
|
| 41 |
+
else:
|
| 42 |
+
print("\n[WARN] No tables found. Run the server to initialize the database.")
|
| 43 |
+
|
| 44 |
+
# Test a simple query
|
| 45 |
+
result = await conn.fetchval("SELECT 1")
|
| 46 |
+
print(f"\n[OK] Test query returned: {result}")
|
| 47 |
+
|
| 48 |
+
await conn.close()
|
| 49 |
+
print("[OK] Connection closed.")
|
| 50 |
+
return True
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"[FAIL] PostgreSQL connection error: {e}")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_qdrant():
|
| 58 |
+
"""Test Qdrant connection and list collections."""
|
| 59 |
+
print("\n" + "=" * 50)
|
| 60 |
+
print(" Testing Qdrant Connection")
|
| 61 |
+
print("=" * 50)
|
| 62 |
+
|
| 63 |
+
qdrant_url = os.getenv("QDRANT_CLIENT")
|
| 64 |
+
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 65 |
+
|
| 66 |
+
if not qdrant_url:
|
| 67 |
+
print("[FAIL] QDRANT_CLIENT not set in .env")
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
print(f"[INFO] Connecting to: {qdrant_url}")
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
from qdrant_client import QdrantClient
|
| 74 |
+
|
| 75 |
+
client = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
|
| 76 |
+
collections = client.get_collections()
|
| 77 |
+
|
| 78 |
+
print("[OK] Connected to Qdrant successfully!")
|
| 79 |
+
|
| 80 |
+
if collections.collections:
|
| 81 |
+
print(f"\n[INFO] Found {len(collections.collections)} collection(s):")
|
| 82 |
+
for col in collections.collections:
|
| 83 |
+
info = client.get_collection(col.name)
|
| 84 |
+
print(f" - {col.name} (vectors: {info.points_count})")
|
| 85 |
+
else:
|
| 86 |
+
print("\n[WARN] No collections found. Run the server to initialize collections.")
|
| 87 |
+
|
| 88 |
+
return True
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
print(f"[FAIL] Qdrant connection error: {e}")
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def main():
|
| 96 |
+
print("\n🔍 Agentrix.io — Database Connection Test\n")
|
| 97 |
+
|
| 98 |
+
pg_ok = await test_postgres()
|
| 99 |
+
qdrant_ok = test_qdrant()
|
| 100 |
+
|
| 101 |
+
print("\n" + "=" * 50)
|
| 102 |
+
print(" Results")
|
| 103 |
+
print("=" * 50)
|
| 104 |
+
print(f" PostgreSQL: {'✅ OK' if pg_ok else '❌ FAILED'}")
|
| 105 |
+
print(f" Qdrant: {'✅ OK' if qdrant_ok else '❌ FAILED'}")
|
| 106 |
+
print("=" * 50)
|
| 107 |
+
|
| 108 |
+
if pg_ok and qdrant_ok:
|
| 109 |
+
print("\n✅ All connections are working!")
|
| 110 |
+
else:
|
| 111 |
+
print("\n❌ Some connections failed. Check your .env file.")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
asyncio.run(main())
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests package for backend optimization modules."""
|