Prince-2025 commited on
Commit
fabaa34
·
verified ·
1 Parent(s): 6217123

Upload 57 files

Browse files

Initial Commit For Deployment.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +42 -0
  2. add_what_happened_column.py +54 -0
  3. agents/__init__.py +38 -0
  4. agents/coding_agent.py +410 -0
  5. agents/convo_agent.py +189 -0
  6. agents/debate_agent.py +64 -0
  7. agents/orchestrator_agent.py +299 -0
  8. agents/smart_orchestrator.py +64 -0
  9. core/__init__.py +12 -0
  10. core/__pycache__/__init__.cpython-310.pyc +0 -0
  11. core/__pycache__/app.cpython-310.pyc +0 -0
  12. core/__pycache__/auth.cpython-310.pyc +0 -0
  13. core/__pycache__/autogen_client.cpython-310.pyc +0 -0
  14. core/__pycache__/config.cpython-310.pyc +0 -0
  15. core/__pycache__/exceptions.cpython-310.pyc +0 -0
  16. core/__pycache__/llm_engine.cpython-310.pyc +0 -0
  17. core/__pycache__/middleware.cpython-310.pyc +0 -0
  18. core/app.py +82 -0
  19. core/auth.py +58 -0
  20. core/autogen_client.py +30 -0
  21. core/config.py +126 -0
  22. core/exceptions.py +54 -0
  23. core/llm_engine.py +35 -0
  24. create_pdf_id_index.py +26 -0
  25. delete.py +66 -0
  26. repositories/__init__.py +35 -0
  27. repositories/postgres_repo.py +521 -0
  28. repositories/qdrant_repo.py +333 -0
  29. requirements.txt +21 -0
  30. routers/__init__.py +8 -0
  31. routers/admin_router.py +181 -0
  32. routers/auth_router.py +53 -0
  33. routers/chat_router.py +550 -0
  34. routers/debate_router.py +74 -0
  35. routers/history_router.py +90 -0
  36. routers/pdf_router.py +86 -0
  37. routers/reflection_router.py +158 -0
  38. schemas/__init__.py +13 -0
  39. schemas/schema.py +86 -0
  40. services/__init__.py +41 -0
  41. services/agent_service.py +234 -0
  42. services/base_stream_service.py +111 -0
  43. services/context_injector.py +94 -0
  44. services/debate_service.py +324 -0
  45. services/deep_research_service.py +314 -0
  46. services/memory_service.py +122 -0
  47. services/rag_service.py +126 -0
  48. services/smart_orchestrator_service.py +623 -0
  49. test_db_connection.py +115 -0
  50. 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."""