AdithyaVardan commited on
Commit
68af3c5
·
1 Parent(s): cef4843

feat: add confluence/slack search tools, chat history, cloud Qdrant support, sync trigger fixes

Browse files
Files changed (50) hide show
  1. .dockerignore +14 -0
  2. .github/workflows/keep-alive.yml +12 -0
  3. .gitignore +3 -0
  4. Dockerfile +26 -0
  5. README.md +0 -9
  6. agent/agents/_gemini.py +29 -19
  7. agent/api.py +7 -1
  8. agent/config.py +14 -5
  9. agent/graph.py +58 -0
  10. agent/models.py +1 -1
  11. agent/prompts.py +12 -8
  12. agent/tools/confluence_search.py +67 -0
  13. agent/tools/doc_search.py +38 -12
  14. agent/tools/slack_search.py +87 -0
  15. agent/tools/sql_query.py +4 -4
  16. agent/tools/ticket_lookup.py +54 -7
  17. frontend/src/App.tsx +1 -1
  18. frontend/src/components/admin/AdminDashboard.tsx +11 -3
  19. frontend/src/components/admin/AdminUserManagement.tsx +142 -144
  20. frontend/src/components/admin/SyncTrigger.tsx +72 -11
  21. frontend/src/components/common/Sidebar.tsx +2 -2
  22. frontend/src/components/query/SuggestedTopics.tsx +5 -5
  23. frontend/src/components/results/KnowledgeGraph.tsx +9 -1
  24. frontend/src/components/results/ResultsPage.tsx +193 -106
  25. frontend/src/components/workspace/QueryHistory.tsx +17 -4
  26. frontend/src/config/env.ts +12 -9
  27. frontend/src/config/routes.ts +6 -1
  28. frontend/src/hooks/useGraphStream.ts +6 -1
  29. frontend/src/hooks/useNotifications.ts +4 -2
  30. frontend/src/pages/AcceptInvitePage.tsx +2 -2
  31. frontend/src/pages/Home.tsx +1 -1
  32. frontend/src/pages/LoginPage.tsx +2 -2
  33. frontend/src/pages/OAuthCallbackPage.tsx +5 -1
  34. frontend/src/pages/WorkspacePage.tsx +4 -3
  35. frontend/src/types/api.ts +1 -1
  36. graph_store/stream.py +41 -5
  37. ingestion/jobs/ingest_job.py +6 -2
  38. ingestion/sources/github.py +7 -4
  39. ingestion/storage/bm25_store.py +13 -4
  40. ingestion/storage/supabase_store.py +1 -1
  41. main.py +20 -1
  42. migrate_qdrant.py +58 -0
  43. requirements.txt +11 -4
  44. src/admin/router.py +8 -6
  45. src/admin/users_api.py +1 -1
  46. src/auth/email.py +21 -18
  47. src/auth/router.py +4 -3
  48. src/confluence_agent/tasks.py +3 -3
  49. src/jira_agent/tasks.py +2 -2
  50. start-ngrok.sh +50 -0
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .venv
3
+ venv
4
+ __pycache__
5
+ *.pyc
6
+ *.pyo
7
+ .git
8
+ .gitignore
9
+ frontend/node_modules
10
+ frontend/src
11
+ frontend/.env
12
+ data/bm25_index.pkl
13
+ *.log
14
+ .DS_Store
.github/workflows/keep-alive.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Keep HF Space Alive
2
+ on:
3
+ schedule:
4
+ - cron: '*/30 * * * *'
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ ping:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Ping space
12
+ run: curl -sf ${{ secrets.HF_SPACE_URL }}/health || true
.gitignore CHANGED
@@ -38,3 +38,6 @@ Thumbs.db
38
  celerybeat-schedule
39
  celerybeat.pid
40
  .env
 
 
 
 
38
  celerybeat-schedule
39
  celerybeat.pid
40
  .env
41
+ frontend/node_modules/
42
+ frontend/package-lock.json
43
+ frontend/tsconfig.tsbuildinfo
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System deps
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential curl git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Install Python deps first (layer cache)
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Pre-download the BGE-M3 model so cold starts are fast
15
+ RUN python3 -c "from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3', use_fp16=False)"
16
+
17
+ # Download spacy model
18
+ RUN python3 -m spacy download en_core_web_sm
19
+
20
+ # Copy app code
21
+ COPY . .
22
+
23
+ # HuggingFace Spaces requires port 7860
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,3 @@
1
- ---
2
- title: GodSpeed
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
  # Godspeed
11
 
12
  Enterprise Knowledge Copilot is a fully open-source, locally-compliant, agentic RAG platform that unifies internal and live external knowledge into a single cited, validated answer engine — purpose-built for IT enterprises operating under GDPR and India's DPDP Act.
 
 
 
 
 
 
 
 
 
 
1
  # Godspeed
2
 
3
  Enterprise Knowledge Copilot is a fully open-source, locally-compliant, agentic RAG platform that unifies internal and live external knowledge into a single cited, validated answer engine — purpose-built for IT enterprises operating under GDPR and India's DPDP Act.
agent/agents/_gemini.py CHANGED
@@ -1,4 +1,4 @@
1
- """Shared Gemini call helper with exponential-backoff retry."""
2
 
3
  from __future__ import annotations
4
 
@@ -7,7 +7,6 @@ import json
7
  import logging
8
  from typing import Any
9
 
10
- from langchain_google_genai import ChatGoogleGenerativeAI
11
  from langchain_core.messages import HumanMessage, SystemMessage
12
 
13
  from agent.config import settings
@@ -15,16 +14,33 @@ from agent.config import settings
15
  logger = logging.getLogger(__name__)
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  async def call_gemini_text(
19
  model_name: str,
20
  system_prompt: str,
21
  user_message: str,
22
  ) -> str:
23
- llm = ChatGoogleGenerativeAI(
24
- model=model_name,
25
- google_api_key=settings.google_api_key,
26
- temperature=0.0,
27
- )
28
  messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
29
 
30
  for attempt in range(settings.gemini_max_retries):
@@ -33,10 +49,10 @@ async def call_gemini_text(
33
  return response.content
34
  except Exception as exc:
35
  if attempt == settings.gemini_max_retries - 1:
36
- logger.error("Gemini call failed after %d retries: %s", settings.gemini_max_retries, exc)
37
  raise
38
  delay = settings.gemini_retry_base_delay * (2 ** attempt)
39
- logger.warning("Gemini call attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
40
  await asyncio.sleep(delay)
41
 
42
  raise RuntimeError("Unreachable")
@@ -48,7 +64,6 @@ async def call_gemini_json(
48
  user_message: str,
49
  ) -> dict[str, Any]:
50
  raw = await call_gemini_text(model_name, system_prompt, user_message)
51
- # Strip markdown code fences if model ignores the instruction
52
  cleaned = raw.strip()
53
  if cleaned.startswith("```"):
54
  cleaned = cleaned.split("\n", 1)[-1]
@@ -57,7 +72,7 @@ async def call_gemini_json(
57
  try:
58
  return json.loads(cleaned)
59
  except json.JSONDecodeError as exc:
60
- logger.error("Failed to parse Gemini JSON response: %s\nRaw: %s", exc, raw[:500])
61
  raise
62
 
63
 
@@ -66,12 +81,7 @@ async def stream_gemini_text(
66
  system_prompt: str,
67
  user_message: str,
68
  ):
69
- llm = ChatGoogleGenerativeAI(
70
- model=model_name,
71
- google_api_key=settings.google_api_key,
72
- temperature=0.0,
73
- streaming=True,
74
- )
75
  messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
76
 
77
  for attempt in range(settings.gemini_max_retries):
@@ -81,8 +91,8 @@ async def stream_gemini_text(
81
  return
82
  except Exception as exc:
83
  if attempt == settings.gemini_max_retries - 1:
84
- logger.error("Gemini stream failed after %d retries: %s", settings.gemini_max_retries, exc)
85
  raise
86
  delay = settings.gemini_retry_base_delay * (2 ** attempt)
87
- logger.warning("Gemini stream attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
88
  await asyncio.sleep(delay)
 
1
+ """LLM call helper with exponential-backoff retry. Uses OpenAI when key is set, else Gemini."""
2
 
3
  from __future__ import annotations
4
 
 
7
  import logging
8
  from typing import Any
9
 
 
10
  from langchain_core.messages import HumanMessage, SystemMessage
11
 
12
  from agent.config import settings
 
14
  logger = logging.getLogger(__name__)
15
 
16
 
17
+ def _make_llm(model_name: str, streaming: bool = False):
18
+ openai_key = getattr(settings, "openai_api_key", "")
19
+ if openai_key:
20
+ from langchain_openai import ChatOpenAI
21
+ # Map Gemini model names to OpenAI equivalents
22
+ oai_model = "gpt-4o-mini"
23
+ return ChatOpenAI(
24
+ model=oai_model,
25
+ api_key=openai_key,
26
+ temperature=0.0,
27
+ streaming=streaming,
28
+ )
29
+ from langchain_google_genai import ChatGoogleGenerativeAI
30
+ return ChatGoogleGenerativeAI(
31
+ model=model_name,
32
+ google_api_key=settings.google_api_key,
33
+ temperature=0.0,
34
+ streaming=streaming,
35
+ )
36
+
37
+
38
  async def call_gemini_text(
39
  model_name: str,
40
  system_prompt: str,
41
  user_message: str,
42
  ) -> str:
43
+ llm = _make_llm(model_name)
 
 
 
 
44
  messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
45
 
46
  for attempt in range(settings.gemini_max_retries):
 
49
  return response.content
50
  except Exception as exc:
51
  if attempt == settings.gemini_max_retries - 1:
52
+ logger.error("LLM call failed after %d retries: %s", settings.gemini_max_retries, exc)
53
  raise
54
  delay = settings.gemini_retry_base_delay * (2 ** attempt)
55
+ logger.warning("LLM call attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
56
  await asyncio.sleep(delay)
57
 
58
  raise RuntimeError("Unreachable")
 
64
  user_message: str,
65
  ) -> dict[str, Any]:
66
  raw = await call_gemini_text(model_name, system_prompt, user_message)
 
67
  cleaned = raw.strip()
68
  if cleaned.startswith("```"):
69
  cleaned = cleaned.split("\n", 1)[-1]
 
72
  try:
73
  return json.loads(cleaned)
74
  except json.JSONDecodeError as exc:
75
+ logger.error("Failed to parse LLM JSON response: %s\nRaw: %s", exc, raw[:500])
76
  raise
77
 
78
 
 
81
  system_prompt: str,
82
  user_message: str,
83
  ):
84
+ llm = _make_llm(model_name, streaming=True)
 
 
 
 
 
85
  messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_message)]
86
 
87
  for attempt in range(settings.gemini_max_retries):
 
91
  return
92
  except Exception as exc:
93
  if attempt == settings.gemini_max_retries - 1:
94
+ logger.error("LLM stream failed after %d retries: %s", settings.gemini_max_retries, exc)
95
  raise
96
  delay = settings.gemini_retry_base_delay * (2 ** attempt)
97
+ logger.warning("LLM stream attempt %d failed (%s); retrying in %.1fs", attempt + 1, exc, delay)
98
  await asyncio.sleep(delay)
agent/api.py CHANGED
@@ -31,12 +31,17 @@ async def _store_query_event(
31
  agent_results: dict | None = None,
32
  guardrail_score: float | None = None,
33
  escalated: bool = False,
 
34
  ) -> None:
35
  """Persist query event to Redis for analytics and workspace history."""
36
  try:
37
  import redis.asyncio as aioredis
38
  from src.config import settings
39
 
 
 
 
 
40
  event = {
41
  "id": str(uuid4()),
42
  "query": query_input.query,
@@ -45,7 +50,7 @@ async def _store_query_event(
45
  "created_at": datetime.utcnow().isoformat(),
46
  "success": success,
47
  "duration_ms": duration_ms,
48
- "answer_brief": "",
49
  # Per-agent retrieval metrics — populated from final graph state
50
  "agents": {
51
  agent: {
@@ -122,6 +127,7 @@ async def _event_generator(
122
  agent_results=final_state.get("agent_results", {}),
123
  guardrail_score=final_state.get("guardrail_score"),
124
  escalated=final_state.get("escalate", False),
 
125
  )
126
  except Exception as exc:
127
  logger.exception(
 
31
  agent_results: dict | None = None,
32
  guardrail_score: float | None = None,
33
  escalated: bool = False,
34
+ answer_text: str = "",
35
  ) -> None:
36
  """Persist query event to Redis for analytics and workspace history."""
37
  try:
38
  import redis.asyncio as aioredis
39
  from src.config import settings
40
 
41
+ brief = answer_text[:500].rstrip() if answer_text else ""
42
+ if answer_text and len(answer_text) > 500:
43
+ brief += "…"
44
+
45
  event = {
46
  "id": str(uuid4()),
47
  "query": query_input.query,
 
50
  "created_at": datetime.utcnow().isoformat(),
51
  "success": success,
52
  "duration_ms": duration_ms,
53
+ "answer_brief": brief,
54
  # Per-agent retrieval metrics — populated from final graph state
55
  "agents": {
56
  agent: {
 
127
  agent_results=final_state.get("agent_results", {}),
128
  guardrail_score=final_state.get("guardrail_score"),
129
  escalated=final_state.get("escalate", False),
130
+ answer_text=final_state.get("final_answer") or "",
131
  )
132
  except Exception as exc:
133
  logger.exception(
agent/config.py CHANGED
@@ -10,14 +10,17 @@ class Settings(BaseSettings):
10
  )
11
 
12
  google_api_key: str = ""
 
13
 
14
- planner_model: str = "gemini-2.5-pro"
15
- synthesiser_model: str = "gemini-2.5-pro"
16
- summariser_model: str = "gemini-2.5-flash"
17
- guardrail_model: str = "gemini-2.5-flash"
18
 
19
  qdrant_host: str = "localhost"
20
  qdrant_port: int = 6333
 
 
21
  qdrant_collection: str = "knowledge_base"
22
  qdrant_dense_vector_name: str = "dense"
23
  qdrant_sparse_vector_name: str = "sparse"
@@ -33,7 +36,7 @@ class Settings(BaseSettings):
33
  rrf_top_k: int = 50
34
  final_top_k: int = 5
35
  reranker_high_threshold: float = 0.6
36
- reranker_medium_threshold: float = 0.4
37
  live_docs_confidence_threshold: float = 0.5
38
 
39
  gemini_max_retries: int = 3
@@ -49,8 +52,14 @@ class Settings(BaseSettings):
49
  # NL-to-SQL tool — direct PostgreSQL connection string.
50
  # e.g. postgresql://postgres:password@db.yourproject.supabase.co:5432/postgres
51
  # Leave empty to disable the sql_query agent gracefully.
 
52
  database_url: str = ""
 
53
  sql_max_rows: int = 20
54
 
 
 
 
 
55
 
56
  settings = Settings()
 
10
  )
11
 
12
  google_api_key: str = ""
13
+ openai_api_key: str = ""
14
 
15
+ planner_model: str = "gemini-2.0-flash"
16
+ synthesiser_model: str = "gemini-2.0-flash"
17
+ summariser_model: str = "gemini-2.0-flash"
18
+ guardrail_model: str = "gemini-2.0-flash"
19
 
20
  qdrant_host: str = "localhost"
21
  qdrant_port: int = 6333
22
+ qdrant_url: str = "" # overrides host+port when set (Qdrant Cloud)
23
+ qdrant_api_key: str = "" # required for Qdrant Cloud
24
  qdrant_collection: str = "knowledge_base"
25
  qdrant_dense_vector_name: str = "dense"
26
  qdrant_sparse_vector_name: str = "sparse"
 
36
  rrf_top_k: int = 50
37
  final_top_k: int = 5
38
  reranker_high_threshold: float = 0.6
39
+ reranker_medium_threshold: float = 0.3
40
  live_docs_confidence_threshold: float = 0.5
41
 
42
  gemini_max_retries: int = 3
 
52
  # NL-to-SQL tool — direct PostgreSQL connection string.
53
  # e.g. postgresql://postgres:password@db.yourproject.supabase.co:5432/postgres
54
  # Leave empty to disable the sql_query agent gracefully.
55
+ # Reads DATABASE_URL or PG_DSN from environment.
56
  database_url: str = ""
57
+ pg_dsn: str = ""
58
  sql_max_rows: int = 20
59
 
60
+ @property
61
+ def effective_database_url(self) -> str:
62
+ return self.database_url or self.pg_dsn
63
+
64
 
65
  settings = Settings()
agent/graph.py CHANGED
@@ -12,8 +12,10 @@ from agent.agents.guardrail import run_guardrail
12
  from agent.agents.planner import run_planner
13
  from agent.agents.synthesiser import stream_synthesis
14
  from agent.models import AgentResult, KnowledgeGraphState, RetrievedChunk
 
15
  from agent.tools.doc_search import compute_retrieval_confidence, run_doc_search
16
  from agent.tools.live_docs import run_live_docs
 
17
  from agent.tools.sql_query import run_sql_query
18
  from agent.tools.ticket_lookup import run_ticket_lookup
19
 
@@ -101,6 +103,56 @@ async def ticket_lookup_node(state: KnowledgeGraphState) -> dict:
101
  return {"agent_results": {**state.agent_results, "ticket_lookup": result}}
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  async def live_docs_node(state: KnowledgeGraphState) -> dict:
105
  queue = state.sse_queue
106
  await _push_event(queue, "agent_started", {"agent": "live_docs"})
@@ -246,6 +298,8 @@ def build_graph() -> Any:
246
  builder.add_node("planner_node", planner_node)
247
  builder.add_node("doc_search_node", doc_search_node)
248
  builder.add_node("ticket_lookup_node", ticket_lookup_node)
 
 
249
  builder.add_node("live_docs_node", live_docs_node)
250
  builder.add_node("sql_query_node", sql_query_node)
251
  builder.add_node("join_node", join_node)
@@ -260,6 +314,8 @@ def build_graph() -> Any:
260
  {
261
  "doc_search_node": "doc_search_node",
262
  "ticket_lookup_node": "ticket_lookup_node",
 
 
263
  "live_docs_node": "live_docs_node",
264
  "sql_query_node": "sql_query_node",
265
  "summariser_node": "synthesiser_node",
@@ -271,6 +327,8 @@ def build_graph() -> Any:
271
  # incoming edge to fire before executing join_node (fan-in).
272
  builder.add_edge("doc_search_node", "join_node")
273
  builder.add_edge("ticket_lookup_node", "join_node")
 
 
274
  builder.add_edge("live_docs_node", "join_node")
275
  builder.add_edge("sql_query_node", "join_node")
276
 
 
12
  from agent.agents.planner import run_planner
13
  from agent.agents.synthesiser import stream_synthesis
14
  from agent.models import AgentResult, KnowledgeGraphState, RetrievedChunk
15
+ from agent.tools.confluence_search import run_confluence_search
16
  from agent.tools.doc_search import compute_retrieval_confidence, run_doc_search
17
  from agent.tools.live_docs import run_live_docs
18
+ from agent.tools.slack_search import run_slack_search
19
  from agent.tools.sql_query import run_sql_query
20
  from agent.tools.ticket_lookup import run_ticket_lookup
21
 
 
103
  return {"agent_results": {**state.agent_results, "ticket_lookup": result}}
104
 
105
 
106
+ async def confluence_search_node(state: KnowledgeGraphState) -> dict:
107
+ queue = state.sse_queue
108
+ await _push_event(queue, "agent_started", {"agent": "confluence_search"})
109
+
110
+ task_input = _find_task_input(state, "confluence_search") or state.query_input.query
111
+ chunks: list[RetrievedChunk] = []
112
+ error: str | None = None
113
+
114
+ try:
115
+ chunks = await run_confluence_search(task_input, state.query_input.team_id)
116
+ except Exception as exc:
117
+ logger.exception("confluence_search_node error")
118
+ error = str(exc)
119
+
120
+ confidence = compute_retrieval_confidence(chunks)
121
+ result = AgentResult(
122
+ agent="confluence_search",
123
+ chunks=chunks,
124
+ retrieval_confidence=confidence,
125
+ error=error,
126
+ )
127
+ await _push_event(queue, "agent_done", {"agent": "confluence_search", "retrieval_confidence": confidence})
128
+ return {"agent_results": {**state.agent_results, "confluence_search": result}}
129
+
130
+
131
+ async def slack_search_node(state: KnowledgeGraphState) -> dict:
132
+ queue = state.sse_queue
133
+ await _push_event(queue, "agent_started", {"agent": "slack_search"})
134
+
135
+ task_input = _find_task_input(state, "slack_search") or state.query_input.query
136
+ chunks: list[RetrievedChunk] = []
137
+ error: str | None = None
138
+
139
+ try:
140
+ chunks = await run_slack_search(task_input, state.query_input.team_id)
141
+ except Exception as exc:
142
+ logger.exception("slack_search_node error")
143
+ error = str(exc)
144
+
145
+ confidence = compute_retrieval_confidence(chunks)
146
+ result = AgentResult(
147
+ agent="slack_search",
148
+ chunks=chunks,
149
+ retrieval_confidence=confidence,
150
+ error=error,
151
+ )
152
+ await _push_event(queue, "agent_done", {"agent": "slack_search", "retrieval_confidence": confidence})
153
+ return {"agent_results": {**state.agent_results, "slack_search": result}}
154
+
155
+
156
  async def live_docs_node(state: KnowledgeGraphState) -> dict:
157
  queue = state.sse_queue
158
  await _push_event(queue, "agent_started", {"agent": "live_docs"})
 
298
  builder.add_node("planner_node", planner_node)
299
  builder.add_node("doc_search_node", doc_search_node)
300
  builder.add_node("ticket_lookup_node", ticket_lookup_node)
301
+ builder.add_node("confluence_search_node", confluence_search_node)
302
+ builder.add_node("slack_search_node", slack_search_node)
303
  builder.add_node("live_docs_node", live_docs_node)
304
  builder.add_node("sql_query_node", sql_query_node)
305
  builder.add_node("join_node", join_node)
 
314
  {
315
  "doc_search_node": "doc_search_node",
316
  "ticket_lookup_node": "ticket_lookup_node",
317
+ "confluence_search_node": "confluence_search_node",
318
+ "slack_search_node": "slack_search_node",
319
  "live_docs_node": "live_docs_node",
320
  "sql_query_node": "sql_query_node",
321
  "summariser_node": "synthesiser_node",
 
327
  # incoming edge to fire before executing join_node (fan-in).
328
  builder.add_edge("doc_search_node", "join_node")
329
  builder.add_edge("ticket_lookup_node", "join_node")
330
+ builder.add_edge("confluence_search_node", "join_node")
331
+ builder.add_edge("slack_search_node", "join_node")
332
  builder.add_edge("live_docs_node", "join_node")
333
  builder.add_edge("sql_query_node", "join_node")
334
 
agent/models.py CHANGED
@@ -16,7 +16,7 @@ class QueryInput(BaseModel):
16
 
17
 
18
  class AgentTask(BaseModel):
19
- agent: Literal["doc_search", "ticket_lookup", "live_docs", "summariser", "sql_query"]
20
  input: str
21
  depends_on: list[str] = Field(default_factory=list)
22
 
 
16
 
17
 
18
  class AgentTask(BaseModel):
19
+ agent: Literal["doc_search", "ticket_lookup", "confluence_search", "slack_search", "live_docs", "summariser", "sql_query"]
20
  input: str
21
  depends_on: list[str] = Field(default_factory=list)
22
 
agent/prompts.py CHANGED
@@ -3,19 +3,23 @@ PLANNER_SYSTEM_PROMPT = """You are a planning agent for an Enterprise Knowledge
3
  Given a user query, decide which retrieval agents are needed and in what order.
4
 
5
  Available agents:
6
- - doc_search: Searches the internal knowledge base (Qdrant vector DB). Use for product docs, runbooks, internal wikis, architecture docs.
7
- - ticket_lookup: Searches Jira tickets. Use when query mentions bugs, issues, tickets, sprints, or task tracking.
 
 
8
  - live_docs: Fetches live web content via Firecrawl/Tavily. Use ONLY when the query mentions a specific external library, framework, or third-party tool where internal docs are insufficient.
9
  - summariser: Summarises a large set of retrieved chunks. Use ONLY when more than 10 chunks are expected.
10
  - sql_query: Queries the internal Supabase database with SQL. Use when the query asks about counts, stats, aggregations, ingestion status, document lists, or any structured/numeric data (e.g. "how many documents", "failed jobs", "which source types", "ingestion stats").
11
 
12
  Rules:
13
- 1. doc_search and ticket_lookup should run in parallel when both are needed — set depends_on: [] for both.
14
- 2. live_docs only runs if you expect doc_search confidence will be low OR the query names a specific external library/framework. Set depends_on: ["doc_search"] to run after.
15
- 3. summariser only runs after doc_search. Set depends_on: ["doc_search"].
16
- 4. Do NOT include agents that are not needed for this query.
17
- 5. Rephrase the input for each agent to be focused and specific to what that agent can retrieve.
18
- 6. sql_query runs independently (depends_on: []). Use it when the query is about structured or aggregated data rather than semantic knowledge retrieval. It can run in parallel with doc_search.
 
 
19
 
20
  Return ONLY valid JSON matching this exact schema. No preamble. No markdown code fences. No explanation outside the JSON.
21
 
 
3
  Given a user query, decide which retrieval agents are needed and in what order.
4
 
5
  Available agents:
6
+ - doc_search: Searches the internal knowledge base (Qdrant vector DB). Use for general product docs, runbooks, architecture docs, GitHub code, and any knowledge not specific to Confluence, Jira, or Slack.
7
+ - ticket_lookup: Searches Jira tickets. Use when query mentions bugs, issues, tickets, sprints, task tracking, or specific ticket IDs (e.g. KAN-7).
8
+ - confluence_search: Searches Confluence pages. Use when query explicitly mentions Confluence, internal wiki pages, design docs, meeting notes, or space/page content.
9
+ - slack_search: Searches Slack messages live. Use when query mentions Slack, team conversations, channel discussions, or asks what was discussed or said about a topic.
10
  - live_docs: Fetches live web content via Firecrawl/Tavily. Use ONLY when the query mentions a specific external library, framework, or third-party tool where internal docs are insufficient.
11
  - summariser: Summarises a large set of retrieved chunks. Use ONLY when more than 10 chunks are expected.
12
  - sql_query: Queries the internal Supabase database with SQL. Use when the query asks about counts, stats, aggregations, ingestion status, document lists, or any structured/numeric data (e.g. "how many documents", "failed jobs", "which source types", "ingestion stats").
13
 
14
  Rules:
15
+ 1. doc_search, ticket_lookup, confluence_search, and slack_search can all run in parallel — set depends_on: [] for each.
16
+ 2. Use confluence_search instead of (or in addition to) doc_search when the query is clearly about Confluence content.
17
+ 3. Use slack_search when the query is clearly about Slack conversations.
18
+ 4. live_docs only runs if you expect doc_search confidence will be low OR the query names a specific external library/framework. Set depends_on: ["doc_search"] to run after.
19
+ 5. summariser only runs after doc_search. Set depends_on: ["doc_search"].
20
+ 6. Do NOT include agents that are not needed for this query.
21
+ 7. Rephrase the input for each agent to be focused and specific to what that agent can retrieve.
22
+ 8. sql_query runs independently (depends_on: []). Use it when the query is about structured or aggregated data rather than semantic knowledge retrieval. It can run in parallel with doc_search.
23
 
24
  Return ONLY valid JSON matching this exact schema. No preamble. No markdown code fences. No explanation outside the JSON.
25
 
agent/tools/confluence_search.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Confluence page search — retrieves ingested Confluence chunks from Qdrant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from agent.config import settings
8
+ from agent.models import RetrievedChunk
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ async def run_confluence_search(query: str, team_id: str) -> list[RetrievedChunk]:
14
+ """Search Qdrant for Confluence chunks matching the query."""
15
+ from qdrant_client import AsyncQdrantClient
16
+ from qdrant_client.http import models as qmodels
17
+ from agent.tools.doc_search import _get_embedding_model
18
+
19
+ try:
20
+ model = _get_embedding_model()
21
+ output = model.encode(
22
+ [query],
23
+ batch_size=1,
24
+ max_length=512,
25
+ return_dense=True,
26
+ return_sparse=True,
27
+ return_colbert_vecs=False,
28
+ )
29
+ dense_vector = output["dense_vecs"][0].tolist()
30
+
31
+ from agent.tools.doc_search import _get_qdrant_client
32
+ client = _get_qdrant_client()
33
+
34
+ conf_filter = qmodels.Filter(
35
+ must=[
36
+ qmodels.FieldCondition(key="source_type", match=qmodels.MatchValue(value="confluence")),
37
+ qmodels.FieldCondition(key="team_id", match=qmodels.MatchValue(value=team_id)),
38
+ ]
39
+ )
40
+
41
+ response = await client.query_points(
42
+ collection_name=settings.qdrant_collection,
43
+ query=dense_vector,
44
+ using=settings.qdrant_dense_vector_name,
45
+ query_filter=conf_filter,
46
+ limit=10,
47
+ with_payload=True,
48
+ )
49
+
50
+ chunks = []
51
+ for hit in response.points:
52
+ p = hit.payload or {}
53
+ chunks.append(RetrievedChunk(
54
+ chunk_id=p.get("chunk_id", str(hit.id)),
55
+ text=p.get("text", ""),
56
+ source=p.get("source", ""),
57
+ source_type="confluence",
58
+ score=hit.score,
59
+ metadata=p.get("metadata", {}),
60
+ ))
61
+
62
+ logger.info("confluence_search: found %d chunks for query=%r team=%s", len(chunks), query, team_id)
63
+ return chunks
64
+
65
+ except Exception:
66
+ logger.exception("confluence_search: search failed")
67
+ return []
agent/tools/doc_search.py CHANGED
@@ -16,6 +16,16 @@ from agent.models import RetrievedChunk
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
 
 
 
 
 
 
 
 
 
19
  _PII_ENTITY_TYPES = [
20
  "person",
21
  "email",
@@ -33,6 +43,7 @@ _gliner = None
33
  _bm25_index: Optional[BM25Okapi] = None
34
  _bm25_corpus: Optional[list[str]] = None
35
  _bm25_doc_ids: Optional[list[str]] = None
 
36
 
37
 
38
  def _get_embedding_model():
@@ -47,9 +58,17 @@ def _get_embedding_model():
47
  def _get_reranker():
48
  global _reranker
49
  if _reranker is None:
50
- from FlagEmbedding import FlagReranker
51
- logger.info("Loading BGE reranker: %s", settings.bge_reranker_model)
52
- _reranker = FlagReranker(settings.bge_reranker_model, use_fp16=True)
 
 
 
 
 
 
 
 
53
  return _reranker
54
 
55
 
@@ -62,15 +81,15 @@ def _get_gliner():
62
  return _gliner
63
 
64
 
65
- def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[list[str]]]:
66
- global _bm25_index, _bm25_corpus, _bm25_doc_ids
67
  if _bm25_index is not None:
68
- return _bm25_index, _bm25_corpus, _bm25_doc_ids
69
 
70
  index_path = Path(settings.bm25_index_path)
71
  if not index_path.exists():
72
  logger.warning("BM25 index not found at %s — skipping BM25 retrieval", index_path)
73
- return None, None, None
74
 
75
  try:
76
  with index_path.open("rb") as f:
@@ -78,12 +97,13 @@ def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[lis
78
  _bm25_index = data["index"]
79
  _bm25_corpus = data["corpus"]
80
  _bm25_doc_ids = data["doc_ids"]
 
81
  logger.info("Loaded BM25 index with %d documents", len(_bm25_doc_ids))
82
  except Exception:
83
  logger.exception("Failed to load BM25 index from %s", index_path)
84
- return None, None, None
85
 
86
- return _bm25_index, _bm25_corpus, _bm25_doc_ids
87
 
88
 
89
  def _mask_pii(text: str) -> str:
@@ -133,7 +153,7 @@ async def run_doc_search(
133
  sparse_values = [sparse_weights[i] for i in sparse_indices]
134
 
135
  bm25_ranked_ids: list[str] = []
136
- bm25, corpus, doc_ids = _load_bm25()
137
  if bm25 is not None and doc_ids:
138
  tokenized = masked_query.lower().split()
139
  bm25_scores = bm25.get_scores(tokenized)
@@ -147,7 +167,7 @@ async def run_doc_search(
147
  qdrant_score_map: dict[str, float] = {}
148
 
149
  try:
150
- client = AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port)
151
 
152
  # Channel-based filter (RBAC): use allowed_channel_ids when set.
153
  # Falls back to team_id for legacy points that pre-date channel tagging.
@@ -224,7 +244,13 @@ async def run_doc_search(
224
  if doc_ids and doc_id in doc_ids:
225
  idx = doc_ids.index(doc_id)
226
  text = corpus[idx] if corpus else ""
227
- payload = {"chunk_id": doc_id, "text": text, "source": "bm25", "source_type": "internal"}
 
 
 
 
 
 
228
  else:
229
  continue
230
  candidates.append({"id": doc_id, "payload": payload, "rrf_score": rrf_scores[doc_id]})
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
+
20
+ def _get_qdrant_client() -> AsyncQdrantClient:
21
+ """Return Qdrant client — cloud if QDRANT_URL is set, otherwise local."""
22
+ if settings.qdrant_url:
23
+ return AsyncQdrantClient(
24
+ url=settings.qdrant_url,
25
+ api_key=settings.qdrant_api_key or None,
26
+ )
27
+ return AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port)
28
+
29
  _PII_ENTITY_TYPES = [
30
  "person",
31
  "email",
 
43
  _bm25_index: Optional[BM25Okapi] = None
44
  _bm25_corpus: Optional[list[str]] = None
45
  _bm25_doc_ids: Optional[list[str]] = None
46
+ _bm25_metadata: Optional[list[dict]] = None
47
 
48
 
49
  def _get_embedding_model():
 
58
  def _get_reranker():
59
  global _reranker
60
  if _reranker is None:
61
+ import numpy as np
62
+ from sentence_transformers import CrossEncoder
63
+ logger.info("Loading CrossEncoder reranker (ms-marco-MiniLM-L-6-v2)")
64
+ _model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
65
+ class _Reranker:
66
+ def compute_score(self, pairs, normalize=True):
67
+ raw = _model.predict(pairs)
68
+ if normalize:
69
+ return list(1 / (1 + np.exp(-raw)))
70
+ return list(raw.tolist())
71
+ _reranker = _Reranker()
72
  return _reranker
73
 
74
 
 
81
  return _gliner
82
 
83
 
84
+ def _load_bm25() -> tuple[Optional[BM25Okapi], Optional[list[str]], Optional[list[str]], Optional[list[dict]]]:
85
+ global _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
86
  if _bm25_index is not None:
87
+ return _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
88
 
89
  index_path = Path(settings.bm25_index_path)
90
  if not index_path.exists():
91
  logger.warning("BM25 index not found at %s — skipping BM25 retrieval", index_path)
92
+ return None, None, None, None
93
 
94
  try:
95
  with index_path.open("rb") as f:
 
97
  _bm25_index = data["index"]
98
  _bm25_corpus = data["corpus"]
99
  _bm25_doc_ids = data["doc_ids"]
100
+ _bm25_metadata = data.get("metadata")
101
  logger.info("Loaded BM25 index with %d documents", len(_bm25_doc_ids))
102
  except Exception:
103
  logger.exception("Failed to load BM25 index from %s", index_path)
104
+ return None, None, None, None
105
 
106
+ return _bm25_index, _bm25_corpus, _bm25_doc_ids, _bm25_metadata
107
 
108
 
109
  def _mask_pii(text: str) -> str:
 
153
  sparse_values = [sparse_weights[i] for i in sparse_indices]
154
 
155
  bm25_ranked_ids: list[str] = []
156
+ bm25, corpus, doc_ids, bm25_metadata = _load_bm25()
157
  if bm25 is not None and doc_ids:
158
  tokenized = masked_query.lower().split()
159
  bm25_scores = bm25.get_scores(tokenized)
 
167
  qdrant_score_map: dict[str, float] = {}
168
 
169
  try:
170
+ client = _get_qdrant_client()
171
 
172
  # Channel-based filter (RBAC): use allowed_channel_ids when set.
173
  # Falls back to team_id for legacy points that pre-date channel tagging.
 
244
  if doc_ids and doc_id in doc_ids:
245
  idx = doc_ids.index(doc_id)
246
  text = corpus[idx] if corpus else ""
247
+ meta = bm25_metadata[idx] if bm25_metadata else {}
248
+ payload = {
249
+ "chunk_id": doc_id,
250
+ "text": text,
251
+ "source": meta.get("source") or meta.get("source_url") or "bm25",
252
+ "source_type": meta.get("source_type", "internal"),
253
+ }
254
  else:
255
  continue
256
  candidates.append({"id": doc_id, "payload": payload, "rrf_score": rrf_scores[doc_id]})
agent/tools/slack_search.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slack search — searches conversation history using bot token (search.messages requires user token)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+
8
+ import httpx
9
+
10
+ from agent.models import RetrievedChunk
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ async def run_slack_search(query: str, team_id: str) -> list[RetrievedChunk]:
16
+ """Search Slack message history for the query by scanning channels the bot is in."""
17
+ token = os.environ.get("SLACK_BOT_TOKEN", "")
18
+ if not token:
19
+ logger.warning("slack_search: SLACK_BOT_TOKEN not set")
20
+ return []
21
+
22
+ keywords = [w.lower() for w in query.split() if len(w) > 3]
23
+ if not keywords:
24
+ keywords = query.lower().split()
25
+
26
+ try:
27
+ async with httpx.AsyncClient(timeout=15) as h:
28
+ # Get channels the bot is a member of
29
+ r = await h.get(
30
+ "https://slack.com/api/conversations.list",
31
+ headers={"Authorization": f"Bearer {token}"},
32
+ params={"types": "public_channel,private_channel", "limit": 200, "exclude_archived": True},
33
+ )
34
+ data = r.json()
35
+ if not data.get("ok"):
36
+ logger.warning("slack_search: conversations.list error: %s", data.get("error"))
37
+ return []
38
+
39
+ channels = [c for c in data.get("channels", []) if c.get("is_member")]
40
+ if not channels:
41
+ logger.info("slack_search: bot is not a member of any channels")
42
+ return []
43
+
44
+ chunks: list[RetrievedChunk] = []
45
+
46
+ for ch in channels[:10]: # cap at 10 channels
47
+ ch_id = ch["id"]
48
+ ch_name = ch.get("name", ch_id)
49
+ try:
50
+ async with httpx.AsyncClient(timeout=15) as h:
51
+ r = await h.get(
52
+ "https://slack.com/api/conversations.history",
53
+ headers={"Authorization": f"Bearer {token}"},
54
+ params={"channel": ch_id, "limit": 200},
55
+ )
56
+ msgs = r.json().get("messages", [])
57
+ except Exception:
58
+ continue
59
+
60
+ for m in msgs:
61
+ text = m.get("text", "")
62
+ if not text:
63
+ continue
64
+ text_lower = text.lower()
65
+ if any(kw in text_lower for kw in keywords):
66
+ ts = m.get("ts", "")
67
+ user = m.get("user", m.get("username", "unknown"))
68
+ link = f"https://slack.com/archives/{ch_id}/p{ts.replace('.', '')}"
69
+ chunks.append(RetrievedChunk(
70
+ chunk_id=f"slack-{ch_id}-{ts}",
71
+ text=f"[#{ch_name}] {text}",
72
+ source=link,
73
+ source_type="slack",
74
+ score=1.0,
75
+ metadata={"channel": ch_name, "channel_id": ch_id, "user": user, "ts": ts},
76
+ ))
77
+ if len(chunks) >= 10:
78
+ break
79
+ if len(chunks) >= 10:
80
+ break
81
+
82
+ logger.info("slack_search: found %d messages for query=%r", len(chunks), query)
83
+ return chunks
84
+
85
+ except Exception:
86
+ logger.exception("slack_search: search failed")
87
+ return []
agent/tools/sql_query.py CHANGED
@@ -94,8 +94,8 @@ async def run_sql_query(
94
  team_id: str,
95
  allowed_channel_ids: list[str] | None = None,
96
  ) -> list[RetrievedChunk]:
97
- if not settings.database_url:
98
- logger.warning("sql_query: DATABASE_URL not configured — skipping")
99
  return []
100
 
101
  # --- Step 1: NL → SQL via Gemini Flash ---
@@ -128,7 +128,7 @@ async def run_sql_query(
128
  return []
129
 
130
  # Replace placeholder with positional parameter for asyncpg
131
- parameterized_sql = raw_sql.replace("'<TEAM_ID_PLACEHOLDER>'", "$1")
132
 
133
  # --- Step 3: Safety validation ---
134
  ok, reason = _validate_sql(parameterized_sql)
@@ -140,7 +140,7 @@ async def run_sql_query(
140
  try:
141
  import asyncpg # imported lazily — only needed when tool is active
142
 
143
- conn = await asyncpg.connect(settings.database_url)
144
  try:
145
  rows = await conn.fetch(parameterized_sql, team_id)
146
  finally:
 
94
  team_id: str,
95
  allowed_channel_ids: list[str] | None = None,
96
  ) -> list[RetrievedChunk]:
97
+ if not settings.effective_database_url:
98
+ logger.warning("sql_query: DATABASE_URL / PG_DSN not configured — skipping")
99
  return []
100
 
101
  # --- Step 1: NL → SQL via Gemini Flash ---
 
128
  return []
129
 
130
  # Replace placeholder with positional parameter for asyncpg
131
+ parameterized_sql = re.sub(r"'<TEAM_ID_PLACEHOLDER>'", "$1", raw_sql, count=1)
132
 
133
  # --- Step 3: Safety validation ---
134
  ok, reason = _validate_sql(parameterized_sql)
 
140
  try:
141
  import asyncpg # imported lazily — only needed when tool is active
142
 
143
+ conn = await asyncpg.connect(settings.effective_database_url)
144
  try:
145
  rows = await conn.fetch(parameterized_sql, team_id)
146
  finally:
agent/tools/ticket_lookup.py CHANGED
@@ -1,4 +1,4 @@
1
- """Jira ticket lookup tool interface stub, no live credentials yet."""
2
 
3
  from __future__ import annotations
4
 
@@ -11,10 +11,57 @@ logger = logging.getLogger(__name__)
11
 
12
 
13
  async def run_ticket_lookup(query: str, team_id: str) -> list[RetrievedChunk]:
14
- """Stub returns empty until JIRA_BASE_URL and JIRA_API_TOKEN are configured."""
15
- if not settings.jira_base_url or not settings.jira_api_token:
16
- logger.info("ticket_lookup: Jira credentials not configured, returning empty results")
17
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- logger.warning("ticket_lookup: stub returning empty results")
20
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jira ticket lookup — searches ingested Jira chunks from Qdrant."""
2
 
3
  from __future__ import annotations
4
 
 
11
 
12
 
13
  async def run_ticket_lookup(query: str, team_id: str) -> list[RetrievedChunk]:
14
+ """Search Qdrant for Jira chunks matching the query."""
15
+ from qdrant_client import AsyncQdrantClient
16
+ from qdrant_client.http import models as qmodels
17
+ from agent.tools.doc_search import _get_embedding_model
18
+
19
+ try:
20
+ model = _get_embedding_model()
21
+ output = model.encode(
22
+ [query],
23
+ batch_size=1,
24
+ max_length=512,
25
+ return_dense=True,
26
+ return_sparse=True,
27
+ return_colbert_vecs=False,
28
+ )
29
+ dense_vector = output["dense_vecs"][0].tolist()
30
+
31
+ from agent.tools.doc_search import _get_qdrant_client
32
+ client = _get_qdrant_client()
33
+
34
+ jira_filter = qmodels.Filter(
35
+ must=[
36
+ qmodels.FieldCondition(key="source_type", match=qmodels.MatchValue(value="jira")),
37
+ qmodels.FieldCondition(key="team_id", match=qmodels.MatchValue(value=team_id)),
38
+ ]
39
+ )
40
 
41
+ response = await client.query_points(
42
+ collection_name=settings.qdrant_collection,
43
+ query=dense_vector,
44
+ using=settings.qdrant_dense_vector_name,
45
+ query_filter=jira_filter,
46
+ limit=10,
47
+ with_payload=True,
48
+ )
49
+
50
+ chunks = []
51
+ for hit in response.points:
52
+ p = hit.payload or {}
53
+ chunks.append(RetrievedChunk(
54
+ chunk_id=p.get("chunk_id", str(hit.id)),
55
+ text=p.get("text", ""),
56
+ source=p.get("source", ""),
57
+ source_type="jira",
58
+ score=hit.score,
59
+ metadata=p.get("metadata", {}),
60
+ ))
61
+
62
+ logger.info("ticket_lookup: found %d chunks for query=%r team=%s", len(chunks), query, team_id)
63
+ return chunks
64
+
65
+ except Exception:
66
+ logger.exception("ticket_lookup: search failed")
67
+ return []
frontend/src/App.tsx CHANGED
@@ -47,7 +47,7 @@ export default function App() {
47
  const handler = (e: KeyboardEvent) => {
48
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
49
  e.preventDefault()
50
- navigate({ to: '/query', search: { q: undefined } })
51
  }
52
  }
53
  window.addEventListener('keydown', handler)
 
47
  const handler = (e: KeyboardEvent) => {
48
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
49
  e.preventDefault()
50
+ navigate({ to: '/query', search: { q: undefined, qid: undefined, fresh: false } })
51
  }
52
  }
53
  window.addEventListener('keydown', handler)
frontend/src/components/admin/AdminDashboard.tsx CHANGED
@@ -5,13 +5,15 @@ import { GraphIngestButton } from './GraphIngestButton'
5
  import { FileUploadWidget } from './FileUploadWidget'
6
  import { DataSourceManager } from './DataSourceManager'
7
  import { SystemLogs } from './SystemLogs'
 
8
 
9
- type Tab = 'overview' | 'data-sources' | 'ingest' | 'logs'
10
 
11
  const TABS: { id: Tab; label: string }[] = [
12
  { id: 'overview', label: 'System Status' },
13
  { id: 'data-sources', label: 'Data Sources' },
14
  { id: 'ingest', label: 'Ingest' },
 
15
  { id: 'logs', label: 'System Logs' },
16
  ]
17
 
@@ -45,8 +47,8 @@ export function AdminDashboard() {
45
  <div className="rounded-xl border border-surface-subtle p-5">
46
  <p className="mb-4 text-sm font-medium text-stone-500">Sync Controls</p>
47
  <div className="flex flex-col gap-3">
48
- <SyncTrigger source="jira" sourceKey="ENG" label="Jira (ENG)" />
49
- <SyncTrigger source="confluence" sourceKey="TEAM" label="Confluence (TEAM)" />
50
  </div>
51
  </div>
52
 
@@ -69,6 +71,12 @@ export function AdminDashboard() {
69
  </div>
70
  )}
71
 
 
 
 
 
 
 
72
  {tab === 'logs' && <SystemLogs />}
73
  </div>
74
  )
 
5
  import { FileUploadWidget } from './FileUploadWidget'
6
  import { DataSourceManager } from './DataSourceManager'
7
  import { SystemLogs } from './SystemLogs'
8
+ import { AdminUserManagement } from './AdminUserManagement'
9
 
10
+ type Tab = 'overview' | 'data-sources' | 'ingest' | 'users' | 'logs'
11
 
12
  const TABS: { id: Tab; label: string }[] = [
13
  { id: 'overview', label: 'System Status' },
14
  { id: 'data-sources', label: 'Data Sources' },
15
  { id: 'ingest', label: 'Ingest' },
16
+ { id: 'users', label: 'Users' },
17
  { id: 'logs', label: 'System Logs' },
18
  ]
19
 
 
47
  <div className="rounded-xl border border-surface-subtle p-5">
48
  <p className="mb-4 text-sm font-medium text-stone-500">Sync Controls</p>
49
  <div className="flex flex-col gap-3">
50
+ <SyncTrigger source="jira" sourceKey="KAN" label="Jira (KAN)" />
51
+ <SyncTrigger source="confluence" sourceKey="Godspeed" label="Confluence (Godspeed)" />
52
  </div>
53
  </div>
54
 
 
71
  </div>
72
  )}
73
 
74
+ {tab === 'users' && (
75
+ <div className="rounded-xl border border-surface-subtle">
76
+ <AdminUserManagement />
77
+ </div>
78
+ )}
79
+
80
  {tab === 'logs' && <SystemLogs />}
81
  </div>
82
  )
frontend/src/components/admin/AdminUserManagement.tsx CHANGED
@@ -1,194 +1,181 @@
1
- import { useState, useRef } from 'react'
2
- import { useQuery, useMutation } from '@tanstack/react-query'
3
  import { apiFetch } from '@/lib/http'
4
- import { UserInvite, BulkUserImport } from '@/types/settings'
5
- import { User } from '@/types/user'
6
  import { LoadingSkeleton } from '../common/LoadingSkeleton'
7
 
 
 
 
 
 
 
 
 
8
  export function AdminUserManagement() {
 
9
  const [showAddForm, setShowAddForm] = useState(false)
10
- const [showBulkImport, setShowBulkImport] = useState(false)
11
- const [formData, setFormData] = useState<UserInvite>({
12
- email: '',
13
- name: '',
14
- role: 'engineer',
15
- })
16
- const fileInputRef = useRef<HTMLInputElement>(null)
17
 
18
- // Fetch users
19
- const { data: users, isLoading, refetch } = useQuery({
20
  queryKey: ['workspace-users'],
21
  queryFn: async () => {
22
- // TODO: GET /api/workspace/users
23
- return [] as User[]
 
24
  },
25
  })
 
26
 
27
- // Add single user
28
- const { mutate: addUser, isPending: isAddingUser } = useMutation({
29
  mutationFn: async (input: UserInvite) => {
30
- // TODO: POST /api/workspace/users (send invitation)
31
- console.log('Add user:', input)
 
 
 
 
 
 
 
 
32
  },
33
- onSuccess: () => {
34
  setFormData({ email: '', name: '', role: 'engineer' })
35
- setShowAddForm(false)
36
- refetch()
 
 
 
 
37
  },
38
  })
39
 
40
- // Bulk import users
41
- const { mutate: bulkImport, isPending: isImporting } = useMutation({
42
- mutationFn: async (file: File) => {
43
- // TODO: POST /api/workspace/users/bulk-import with CSV/Excel parsing
44
- console.log('Bulk import file:', file)
45
- // For now, mock parsing
46
- return { count: 0 } as { count: number }
47
- },
48
- onSuccess: () => {
49
- setShowBulkImport(false)
50
- if (fileInputRef.current) fileInputRef.current.value = ''
51
- refetch()
52
  },
 
53
  })
54
 
55
- const handleAddUser = () => {
56
  if (!formData.email.trim() || !formData.name.trim()) return
57
- addUser(formData)
58
- }
59
-
60
- const handleBulkImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
61
- const file = e.currentTarget.files?.[0]
62
- if (file) {
63
- bulkImport(file)
64
- }
65
  }
66
 
67
  return (
68
  <div className="space-y-6 p-6">
69
- {/* Header with Actions */}
70
  <div className="flex items-center justify-between">
71
  <div>
72
  <h3 className="text-sm font-semibold">Workspace Users</h3>
73
- <p className="mt-1 text-xs text-stone-500">{users?.length || 0} users total</p>
74
- </div>
75
- <div className="flex gap-2">
76
- <button
77
- onClick={() => setShowAddForm(!showAddForm)}
78
- className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark"
79
- >
80
- + Add User
81
- </button>
82
- <button
83
- onClick={() => setShowBulkImport(!showBulkImport)}
84
- className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
85
- >
86
- 📥 Import CSV
87
- </button>
88
  </div>
 
 
 
 
 
 
89
  </div>
90
 
91
- {/* Add User Form */}
92
  {showAddForm && (
93
  <div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-stone-900">
94
- <div>
95
- <label className="block text-sm font-medium text-stone-700 dark:text-stone-300">
96
- Name
97
- </label>
98
- <input
99
- type="text"
100
- value={formData.name}
101
- onChange={(e) => setFormData({ ...formData, name: e.target.value })}
102
- placeholder="John Doe"
103
- className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
104
- />
105
- </div>
106
-
107
- <div>
108
- <label className="block text-sm font-medium text-stone-700 dark:text-stone-300">
109
- Email
110
- </label>
111
- <input
112
- type="email"
113
- value={formData.email}
114
- onChange={(e) => setFormData({ ...formData, email: e.target.value })}
115
- placeholder="john@example.com"
116
- className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
117
- />
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
 
120
- <div>
121
- <label className="block text-sm font-medium text-stone-700 dark:text-stone-300">
122
- Role
123
- </label>
124
- <select
125
- value={formData.role}
126
- onChange={(e) =>
127
- setFormData({
128
- ...formData,
129
- role: e.target.value as 'engineer' | 'manager' | 'admin',
130
- })
131
- }
132
- className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-stone-900 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
133
- >
134
- <option value="engineer">Engineer</option>
135
- <option value="manager">Manager</option>
136
- <option value="admin">Admin</option>
137
- </select>
138
- </div>
139
 
140
  <div className="flex gap-2">
141
  <button
142
- onClick={handleAddUser}
143
- disabled={isAddingUser || !formData.email.trim() || !formData.name.trim()}
144
  className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark disabled:opacity-50"
145
  >
146
- {isAddingUser ? 'Adding...' : 'Add User'}
147
  </button>
148
  <button
149
- onClick={() => setShowAddForm(false)}
150
  className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
151
  >
152
  Cancel
153
  </button>
154
  </div>
155
- </div>
156
- )}
157
 
158
- {/* Bulk Import Form */}
159
- {showBulkImport && (
160
- <div className="space-y-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-stone-900">
161
- <div>
162
- <label className="block text-sm font-medium text-stone-700 dark:text-stone-300">
163
- Upload CSV or Excel file
164
- </label>
165
- <p className="mt-1 text-xs text-stone-600 dark:text-stone-400">
166
- Format: name, email, role (engineer|manager|admin)
167
- </p>
168
- <input
169
- ref={fileInputRef}
170
- type="file"
171
- accept=".csv,.xlsx,.xls"
172
- onChange={handleBulkImport}
173
- disabled={isImporting}
174
- className="mt-2 block w-full text-sm text-stone-500 file:rounded file:border-0 file:bg-brand file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-brand-dark dark:text-stone-400"
175
- />
176
- </div>
177
- <button
178
- onClick={() => setShowBulkImport(false)}
179
- className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
180
- >
181
- Cancel
182
- </button>
183
  </div>
184
  )}
185
 
186
- {/* Users List */}
187
  <div>
188
- <h4 className="mb-3 text-sm font-semibold">Active Users</h4>
189
  {isLoading ? (
190
  <LoadingSkeleton rows={5} className="h-10" />
191
- ) : users && users.length > 0 ? (
192
  <div className="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
193
  <table className="w-full text-left text-sm">
194
  <thead className="border-b border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
@@ -196,10 +183,8 @@ export function AdminUserManagement() {
196
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Name</th>
197
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Email</th>
198
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Role</th>
199
- <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Team</th>
200
- <th className="px-4 py-2 text-right font-semibold text-stone-700 dark:text-stone-300">
201
- Actions
202
- </th>
203
  </tr>
204
  </thead>
205
  <tbody className="divide-y divide-stone-200 dark:divide-stone-700">
@@ -212,9 +197,22 @@ export function AdminUserManagement() {
212
  {u.role}
213
  </span>
214
  </td>
215
- <td className="px-4 py-3 text-stone-600 dark:text-stone-400">{u.team?.name || '—'}</td>
 
 
 
 
 
 
 
 
216
  <td className="px-4 py-3 text-right">
217
- <button className="text-xs font-medium text-stone-500 hover:text-red-600">
 
 
 
 
 
218
  Remove
219
  </button>
220
  </td>
@@ -226,7 +224,7 @@ export function AdminUserManagement() {
226
  ) : (
227
  <div className="rounded-lg border-2 border-dashed border-stone-300 p-6 text-center dark:border-stone-600">
228
  <p className="text-sm text-stone-500">No users yet</p>
229
- <p className="mt-1 text-xs text-stone-400">Add your first user to get started</p>
230
  </div>
231
  )}
232
  </div>
 
1
+ import { useState } from 'react'
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
  import { apiFetch } from '@/lib/http'
4
+ import { UserInvite } from '@/types/settings'
 
5
  import { LoadingSkeleton } from '../common/LoadingSkeleton'
6
 
7
+ interface WorkspaceUser {
8
+ id: string
9
+ email: string
10
+ name: string
11
+ role: string
12
+ is_active: boolean
13
+ }
14
+
15
  export function AdminUserManagement() {
16
+ const qc = useQueryClient()
17
  const [showAddForm, setShowAddForm] = useState(false)
18
+ const [formData, setFormData] = useState<UserInvite>({ email: '', name: '', role: 'engineer' })
19
+ const [inviteLink, setInviteLink] = useState<{ url: string; emailSent: boolean } | null>(null)
20
+ const [error, setError] = useState<string | null>(null)
 
 
 
 
21
 
22
+ const { data, isLoading } = useQuery({
 
23
  queryKey: ['workspace-users'],
24
  queryFn: async () => {
25
+ const res = await apiFetch('/api/workspace/users')
26
+ const json = await res.json()
27
+ return (json.users ?? []) as WorkspaceUser[]
28
  },
29
  })
30
+ const users = data ?? []
31
 
32
+ const { mutate: sendInvite, isPending: isSending } = useMutation({
 
33
  mutationFn: async (input: UserInvite) => {
34
+ const res = await apiFetch('/api/auth/invite', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ email: input.email, name: input.name, role: input.role }),
38
+ })
39
+ if (!res.ok) {
40
+ const body = await res.json().catch(() => ({}))
41
+ throw new Error(body.detail ?? `Error ${res.status}`)
42
+ }
43
+ return res.json() as Promise<{ ok: boolean; email: string; invite_url: string; email_sent: boolean }>
44
  },
45
+ onSuccess: (data) => {
46
  setFormData({ email: '', name: '', role: 'engineer' })
47
+ setError(null)
48
+ setInviteLink({ url: data.invite_url, emailSent: data.email_sent })
49
+ qc.invalidateQueries({ queryKey: ['workspace-users'] })
50
+ },
51
+ onError: (err: Error) => {
52
+ setError(err.message)
53
  },
54
  })
55
 
56
+ const { mutate: removeUser } = useMutation({
57
+ mutationFn: async (userId: string) => {
58
+ const res = await apiFetch(`/api/workspace/users/${userId}`, { method: 'DELETE' })
59
+ if (!res.ok) throw new Error(`Error ${res.status}`)
 
 
 
 
 
 
 
 
60
  },
61
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace-users'] }),
62
  })
63
 
64
+ const handleInvite = () => {
65
  if (!formData.email.trim() || !formData.name.trim()) return
66
+ setError(null)
67
+ setInviteLink(null as null)
68
+ sendInvite(formData)
 
 
 
 
 
69
  }
70
 
71
  return (
72
  <div className="space-y-6 p-6">
73
+ {/* Header */}
74
  <div className="flex items-center justify-between">
75
  <div>
76
  <h3 className="text-sm font-semibold">Workspace Users</h3>
77
+ <p className="mt-1 text-xs text-stone-500">{users.length} user{users.length !== 1 ? 's' : ''}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
+ <button
80
+ onClick={() => { setShowAddForm(!showAddForm); setInviteLink(null as null); setError(null) }}
81
+ className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark"
82
+ >
83
+ + Invite User
84
+ </button>
85
  </div>
86
 
87
+ {/* Invite Form */}
88
  {showAddForm && (
89
  <div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-stone-900">
90
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
91
+ <div>
92
+ <label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Name</label>
93
+ <input
94
+ type="text"
95
+ value={formData.name}
96
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
97
+ placeholder="Jane Smith"
98
+ className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
99
+ />
100
+ </div>
101
+ <div>
102
+ <label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Email</label>
103
+ <input
104
+ type="email"
105
+ value={formData.email}
106
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
107
+ placeholder="jane@company.com"
108
+ className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
109
+ />
110
+ </div>
111
+ <div>
112
+ <label className="block text-xs font-medium text-stone-700 dark:text-stone-300">Role</label>
113
+ <select
114
+ value={formData.role}
115
+ onChange={(e) => setFormData({ ...formData, role: e.target.value as UserInvite['role'] })}
116
+ className="mt-1 block w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
117
+ >
118
+ <option value="engineer">Engineer</option>
119
+ <option value="manager">Manager</option>
120
+ <option value="admin">Admin</option>
121
+ <option value="org_admin">Org Admin</option>
122
+ </select>
123
+ </div>
124
  </div>
125
 
126
+ {error && (
127
+ <p className="rounded bg-red-100 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
128
+ {error}
129
+ </p>
130
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  <div className="flex gap-2">
133
  <button
134
+ onClick={handleInvite}
135
+ disabled={isSending || !formData.email.trim() || !formData.name.trim()}
136
  className="rounded bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-dark disabled:opacity-50"
137
  >
138
+ {isSending ? 'Sending...' : 'Send Invite'}
139
  </button>
140
  <button
141
+ onClick={() => { setShowAddForm(false); setInviteLink(null as null); setError(null) }}
142
  className="rounded border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-700"
143
  >
144
  Cancel
145
  </button>
146
  </div>
 
 
147
 
148
+ {inviteLink && (
149
+ <div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
150
+ <p className="mb-1 text-xs font-medium text-green-800 dark:text-green-300">
151
+ {inviteLink.emailSent
152
+ ? 'Invite email sent! Share the link below as backup:'
153
+ : 'Email not configured — share this link directly with the user:'}
154
+ </p>
155
+ <div className="flex items-center gap-2">
156
+ <input
157
+ readOnly
158
+ value={inviteLink.url}
159
+ className="flex-1 rounded border border-green-300 bg-white px-2 py-1.5 text-xs font-mono text-stone-800 dark:border-green-700 dark:bg-stone-800 dark:text-stone-200"
160
+ />
161
+ <button
162
+ onClick={() => navigator.clipboard.writeText(inviteLink.url)}
163
+ className="rounded border border-green-300 px-2 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/40"
164
+ >
165
+ Copy
166
+ </button>
167
+ </div>
168
+ <p className="mt-1 text-xs text-green-700 dark:text-green-400">Expires in 7 days.</p>
169
+ </div>
170
+ )}
 
 
171
  </div>
172
  )}
173
 
174
+ {/* Users Table */}
175
  <div>
 
176
  {isLoading ? (
177
  <LoadingSkeleton rows={5} className="h-10" />
178
+ ) : users.length > 0 ? (
179
  <div className="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
180
  <table className="w-full text-left text-sm">
181
  <thead className="border-b border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
 
183
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Name</th>
184
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Email</th>
185
  <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Role</th>
186
+ <th className="px-4 py-2 font-semibold text-stone-700 dark:text-stone-300">Status</th>
187
+ <th className="px-4 py-2 text-right font-semibold text-stone-700 dark:text-stone-300">Actions</th>
 
 
188
  </tr>
189
  </thead>
190
  <tbody className="divide-y divide-stone-200 dark:divide-stone-700">
 
197
  {u.role}
198
  </span>
199
  </td>
200
+ <td className="px-4 py-3">
201
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
202
+ u.is_active
203
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
204
+ : 'bg-stone-100 text-stone-500 dark:bg-stone-700 dark:text-stone-400'
205
+ }`}>
206
+ {u.is_active ? 'Active' : 'Inactive'}
207
+ </span>
208
+ </td>
209
  <td className="px-4 py-3 text-right">
210
+ <button
211
+ onClick={() => {
212
+ if (confirm(`Remove ${u.name} from workspace?`)) removeUser(u.id)
213
+ }}
214
+ className="text-xs font-medium text-stone-500 hover:text-red-600"
215
+ >
216
  Remove
217
  </button>
218
  </td>
 
224
  ) : (
225
  <div className="rounded-lg border-2 border-dashed border-stone-300 p-6 text-center dark:border-stone-600">
226
  <p className="text-sm text-stone-500">No users yet</p>
227
+ <p className="mt-1 text-xs text-stone-400">Click "Invite User" to add your first team member</p>
228
  </div>
229
  )}
230
  </div>
frontend/src/components/admin/SyncTrigger.tsx CHANGED
@@ -1,23 +1,70 @@
1
- import { useState } from 'react'
2
  import { apiFetch, ApiError } from '@/lib/http'
3
  import { useUIStore } from '@/stores/uiStore'
4
  import { cn } from '@/lib/utils'
5
 
 
 
6
  interface Props {
7
  source: 'jira' | 'confluence'
8
- /** project key for Jira, space key for Confluence */
9
  sourceKey: string
10
  label: string
11
  lastSynced?: string
12
  }
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
15
- const [taskId, setTaskId] = useState<string | null>(null)
16
- const [loading, setLoading] = useState(false)
17
- const addToast = useUIStore((s) => s.addToast)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  const trigger = async () => {
20
  setLoading(true)
 
 
21
  try {
22
  const path = source === 'jira'
23
  ? `/jira/sync/${sourceKey}`
@@ -25,39 +72,53 @@ export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
25
  const res = await apiFetch(path, { method: 'POST' })
26
  const data = await res.json() as { task_id: string }
27
  setTaskId(data.task_id)
 
28
  addToast({ type: 'success', message: `${label} sync queued` })
29
  } catch (err) {
30
  if (!(err instanceof ApiError)) {
31
  addToast({ type: 'error', message: `Failed to trigger ${label} sync` })
32
  }
33
- // ApiError toasts are handled in apiFetch
34
  } finally {
35
  setLoading(false)
36
  }
37
  }
38
 
 
 
39
  return (
40
  <div className="flex items-center justify-between rounded-lg border border-surface-subtle p-4">
41
  <div>
42
  <p className="text-sm font-medium">{label}</p>
43
- {lastSynced && (
44
  <p className="text-xs text-stone-500">Last synced {lastSynced}</p>
45
  )}
 
 
 
 
 
 
 
 
 
 
46
  {taskId && (
47
- <p className="mt-0.5 font-mono text-xs text-stone-400">Task {taskId.slice(0, 8)}…</p>
 
 
48
  )}
49
  </div>
50
  <button
51
  onClick={trigger}
52
- disabled={loading}
53
  className={cn(
54
  'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
55
- loading
56
  ? 'cursor-not-allowed bg-stone-100 text-stone-400 dark:bg-stone-800'
57
  : 'bg-brand text-white hover:bg-brand-dark',
58
  )}
59
  >
60
- {loading ? 'Queuing…' : 'Sync now'}
61
  </button>
62
  </div>
63
  )
 
1
+ import { useState, useEffect, useRef } from 'react'
2
  import { apiFetch, ApiError } from '@/lib/http'
3
  import { useUIStore } from '@/stores/uiStore'
4
  import { cn } from '@/lib/utils'
5
 
6
+ type JobStatus = 'pending' | 'running' | 'completed' | 'failed'
7
+
8
  interface Props {
9
  source: 'jira' | 'confluence'
 
10
  sourceKey: string
11
  label: string
12
  lastSynced?: string
13
  }
14
 
15
+ const STATUS_LABEL: Record<JobStatus, string> = {
16
+ pending: 'Queued…',
17
+ running: 'Syncing…',
18
+ completed: 'Sync complete',
19
+ failed: 'Sync failed',
20
+ }
21
+
22
+ const STATUS_COLOR: Record<JobStatus, string> = {
23
+ pending: 'text-stone-400',
24
+ running: 'text-blue-500',
25
+ completed: 'text-green-600',
26
+ failed: 'text-red-500',
27
+ }
28
+
29
  export function SyncTrigger({ source, sourceKey, label, lastSynced }: Props) {
30
+ const [taskId, setTaskId] = useState<string | null>(null)
31
+ const [jobStatus, setJobStatus] = useState<JobStatus | null>(null)
32
+ const [loading, setLoading] = useState(false)
33
+ const addToast = useUIStore((s) => s.addToast)
34
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
35
+
36
+ // Poll job status until terminal state
37
+ useEffect(() => {
38
+ if (!taskId) return
39
+ if (pollRef.current) clearInterval(pollRef.current)
40
+
41
+ const poll = async () => {
42
+ try {
43
+ const res = await apiFetch(`/ingest/jobs/${taskId}`)
44
+ const data = await res.json() as { status: JobStatus }
45
+ setJobStatus(data.status)
46
+ if (data.status === 'completed' || data.status === 'failed') {
47
+ clearInterval(pollRef.current!)
48
+ pollRef.current = null
49
+ addToast({
50
+ type: data.status === 'completed' ? 'success' : 'error',
51
+ message: data.status === 'completed'
52
+ ? `${label} sync finished`
53
+ : `${label} sync failed`,
54
+ })
55
+ }
56
+ } catch { /* ignore poll errors */ }
57
+ }
58
+
59
+ poll() // immediate first check
60
+ pollRef.current = setInterval(poll, 4000)
61
+ return () => { if (pollRef.current) clearInterval(pollRef.current) }
62
+ }, [taskId]) // eslint-disable-line react-hooks/exhaustive-deps
63
 
64
  const trigger = async () => {
65
  setLoading(true)
66
+ setTaskId(null)
67
+ setJobStatus(null)
68
  try {
69
  const path = source === 'jira'
70
  ? `/jira/sync/${sourceKey}`
 
72
  const res = await apiFetch(path, { method: 'POST' })
73
  const data = await res.json() as { task_id: string }
74
  setTaskId(data.task_id)
75
+ setJobStatus('pending')
76
  addToast({ type: 'success', message: `${label} sync queued` })
77
  } catch (err) {
78
  if (!(err instanceof ApiError)) {
79
  addToast({ type: 'error', message: `Failed to trigger ${label} sync` })
80
  }
 
81
  } finally {
82
  setLoading(false)
83
  }
84
  }
85
 
86
+ const isBusy = loading || jobStatus === 'pending' || jobStatus === 'running'
87
+
88
  return (
89
  <div className="flex items-center justify-between rounded-lg border border-surface-subtle p-4">
90
  <div>
91
  <p className="text-sm font-medium">{label}</p>
92
+ {lastSynced && !jobStatus && (
93
  <p className="text-xs text-stone-500">Last synced {lastSynced}</p>
94
  )}
95
+ {jobStatus && (
96
+ <div className="mt-1 flex items-center gap-1.5">
97
+ {(jobStatus === 'pending' || jobStatus === 'running') && (
98
+ <span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border-2 border-blue-300 border-t-blue-600" />
99
+ )}
100
+ <p className={cn('text-xs font-medium', STATUS_COLOR[jobStatus])}>
101
+ {STATUS_LABEL[jobStatus]}
102
+ </p>
103
+ </div>
104
+ )}
105
  {taskId && (
106
+ <p className="mt-0.5 font-mono text-[10px] text-stone-400">
107
+ Task {taskId.slice(0, 8)}…
108
+ </p>
109
  )}
110
  </div>
111
  <button
112
  onClick={trigger}
113
+ disabled={isBusy}
114
  className={cn(
115
  'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
116
+ isBusy
117
  ? 'cursor-not-allowed bg-stone-100 text-stone-400 dark:bg-stone-800'
118
  : 'bg-brand text-white hover:bg-brand-dark',
119
  )}
120
  >
121
+ {loading ? 'Queuing…' : isBusy ? 'Syncing…' : 'Sync now'}
122
  </button>
123
  </div>
124
  )
frontend/src/components/common/Sidebar.tsx CHANGED
@@ -165,7 +165,7 @@ export function Sidebar() {
165
  {recent.map((item) => (
166
  <button
167
  key={item.id}
168
- onClick={() => navigate({ to: '/query', search: { q: item.query } })}
169
  title={item.query}
170
  className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-stone-50 dark:hover:bg-stone-900"
171
  >
@@ -175,7 +175,7 @@ export function Sidebar() {
175
  item.success ? 'bg-green-400' : 'bg-stone-300',
176
  )}
177
  />
178
- <span className="truncate text-xs text-stone-500 dark:text-stone-400">
179
  {item.query.length > 34 ? item.query.slice(0, 34) + '…' : item.query}
180
  </span>
181
  </button>
 
165
  {recent.map((item) => (
166
  <button
167
  key={item.id}
168
+ onClick={() => navigate({ to: '/query', search: { q: item.query, qid: undefined, fresh: false } })}
169
  title={item.query}
170
  className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-stone-50 dark:hover:bg-stone-900"
171
  >
 
175
  item.success ? 'bg-green-400' : 'bg-stone-300',
176
  )}
177
  />
178
+ <span className="truncate text-xs text-stone-500 dark:text-stone-400" title={item.query}>
179
  {item.query.length > 34 ? item.query.slice(0, 34) + '…' : item.query}
180
  </span>
181
  </button>
frontend/src/components/query/SuggestedTopics.tsx CHANGED
@@ -3,11 +3,11 @@ interface Props {
3
  }
4
 
5
  const DEFAULTS = [
6
- 'What services does the auth team own?',
7
- 'Show me recent incidents affecting the payments service',
8
- 'Which libraries are deprecated in our stack?',
9
- 'What is the onboarding process for new engineers?',
10
- 'Summarise open Jira tickets for the infra team',
11
  ]
12
 
13
  export function SuggestedTopics({ onSelect }: Props) {
 
3
  }
4
 
5
  const DEFAULTS = [
6
+ 'What is Godspeed and what does it do?',
7
+ 'How do I set up Godspeed locally?',
8
+ 'What are the main components of the Godspeed architecture?',
9
+ 'What API endpoints does Godspeed expose?',
10
+ 'What is the incident runbook for Godspeed?',
11
  ]
12
 
13
  export function SuggestedTopics({ onSelect }: Props) {
frontend/src/components/results/KnowledgeGraph.tsx CHANGED
@@ -68,6 +68,13 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
68
  if (!containerRef.current) return
69
 
70
  const el = containerRef.current
 
 
 
 
 
 
 
71
  const fg = ForceGraph2D()
72
  fg(el)
73
  fg.backgroundColor('transparent')
@@ -85,7 +92,7 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
85
  onNodeClick({ id: n.id, label: n.label, name: n.name })
86
  })
87
  .onNodeHover((n: FGNode | null) => {
88
- onNodeHover(n ? { id: n.id, label: n.label, name: n.name } : null, 0, 0)
89
  })
90
  graphRef.current = fg
91
 
@@ -108,6 +115,7 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
108
  ro?.disconnect()
109
  graphRef.current?._destructor?.()
110
  graphRef.current = null
 
111
  }
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
113
  }, [])
 
68
  if (!containerRef.current) return
69
 
70
  const el = containerRef.current
71
+
72
+ // Track real mouse position since force-graph's onNodeHover doesn't provide it
73
+ let mouseX = 0
74
+ let mouseY = 0
75
+ const trackMouse = (e: MouseEvent) => { mouseX = e.clientX; mouseY = e.clientY }
76
+ el.addEventListener('mousemove', trackMouse)
77
+
78
  const fg = ForceGraph2D()
79
  fg(el)
80
  fg.backgroundColor('transparent')
 
92
  onNodeClick({ id: n.id, label: n.label, name: n.name })
93
  })
94
  .onNodeHover((n: FGNode | null) => {
95
+ onNodeHover(n ? { id: n.id, label: n.label, name: n.name } : null, mouseX, mouseY)
96
  })
97
  graphRef.current = fg
98
 
 
115
  ro?.disconnect()
116
  graphRef.current?._destructor?.()
117
  graphRef.current = null
118
+ // trackMouse listener is on el which is removed from DOM — GC handles it
119
  }
120
  // eslint-disable-next-line react-hooks/exhaustive-deps
121
  }, [])
frontend/src/components/results/ResultsPage.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { useSearch } from '@tanstack/react-router'
3
  import { useAuthStore } from '@/stores/authStore'
4
  import { useUIStore } from '@/stores/uiStore'
5
  import { useSSEStream } from '@/hooks/useSSEStream'
@@ -27,13 +27,28 @@ import type {
27
 
28
  type Tab = 'graph' | 'answer'
29
 
 
 
 
 
 
 
 
 
 
 
30
  export function ResultsPage() {
31
  const user = useAuthStore((s) => s.user)
 
32
  const { graphCollapsed, toggleGraphCollapsed } = useUIStore()
33
  const sessionRef = useRef(crypto.randomUUID())
34
- const { q: initialQuery } = useSearch({ from: '/query' })
 
35
 
36
- // ── SSE state ───────────────────────────────────────────────────────────────
 
 
 
37
  const [plan, setPlan] = useState<AgentTask[]>([])
38
  const [agentStatuses, setStatuses] = useState<Record<string, AgentStatus>>({})
39
  const [answerText, setAnswerText] = useState('')
@@ -43,28 +58,39 @@ export function ResultsPage() {
43
  const [queryId, setQueryId] = useState('')
44
  const [shareOpen, setShareOpen] = useState(false)
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  // ── Graph state ─────────────────────────────────────────────────────────────
47
  const [graphNodes, setGraphNodes] = useState<GraphNode[]>([])
48
  const [graphEdges, setGraphEdges] = useState<GraphEdge[]>([])
49
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null)
50
  const [hoverPos, setHoverPos] = useState({ x: 0, y: 0 })
51
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
52
- // Accumulated in refs to avoid setState on every 50ms node message
53
  const nodesAccRef = useRef<GraphNode[]>([])
54
  const edgesAccRef = useRef<GraphEdge[]>([])
55
 
56
  // ── Mobile tab ──────────────────────────────────────────────────────────────
57
  const [activeTab, setActiveTab] = useState<Tab>('graph')
58
- // Local — not persisted; maximize is a session-level power-user action
59
  const [graphMaximized, setGraphMaximized] = useState(false)
60
 
61
  // ── Hooks ───────────────────────────────────────────────────────────────────
62
  const { state, error, firstEventArrived, stream } = useSSEStream()
63
  const { gState, retryCount, connect, disconnect } = useGraphStream()
64
 
65
- // ── Shared graph reconnect ───────────────────────────────────────────────────
66
- // Used both by runQuery (new query) and the standalone "Reload / Try again" buttons.
67
- // Clears accumulated state then opens a fresh WS to /graph/stream.
 
68
  const reconnectGraph = useCallback(() => {
69
  nodesAccRef.current = []
70
  edgesAccRef.current = []
@@ -88,7 +114,21 @@ export function ResultsPage() {
88
  // ── Query runner ─────────────────────────────────────────────────────────────
89
  const runQuery = useCallback(
90
  async (query: string) => {
91
- // Reset all state for the new query
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  setPlan([])
93
  setStatuses({})
94
  setAnswerText('')
@@ -97,7 +137,7 @@ export function ResultsPage() {
97
  setCurrentQuery(query)
98
  setQueryId(crypto.randomUUID())
99
  setShareOpen(false)
100
- setActiveTab('graph') // always start on graph tab so user sees it populate
101
  setSelectedNode(null)
102
 
103
  reconnectGraph()
@@ -153,13 +193,65 @@ export function ResultsPage() {
153
  setSelectedNode(node)
154
  }, [])
155
 
156
- // Auto-fire from URL ?q= on mount
157
  const runQueryRef = useRef(runQuery)
158
  runQueryRef.current = runQuery
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  useEffect(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  if (initialQuery) runQueryRef.current(initialQuery)
161
  // eslint-disable-next-line react-hooks/exhaustive-deps
162
- }, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
  // ── Derived ─────────────────────────────────────────────────────────────────
165
  const isLoading = state === 'loading'
@@ -167,36 +259,34 @@ export function ResultsPage() {
167
  const isComplete = state === 'complete'
168
  const isError = state === 'error'
169
  const isActive = isLoading || isStreaming
170
- const hasData = firstEventArrived.current // set when first SSE event arrives
171
 
172
- // ── Tab indicators ──────────────────────────────────────────────────────────
173
  const graphHasContent = graphNodes.length > 0
174
  const answerHasContent = !!answerText || plan.length > 0
175
 
176
  return (
177
  <div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-8">
178
 
179
- {/* Search bar — always visible */}
180
  <SearchBox
 
181
  onSubmit={runQuery}
182
  disabled={isActive}
183
- defaultValue={initialQuery ?? ''}
184
  />
185
 
186
- {/* Idle home state — no query yet */}
187
- {state === 'idle' && !currentQuery && (
188
  <div className="flex flex-1 items-center justify-center">
189
  <p className="text-sm text-stone-400">Ask a question to get started.</p>
190
  </div>
191
  )}
192
 
193
- {/* ── Results layout — mounted immediately when query fires ─────────── */}
194
- {currentQuery && (
195
  <>
196
- {/* Mobile tab bar — hidden on lg where both columns are visible */}
197
- {/* Answer tab is disabled (grey) until first SSE event arrives */}
198
  <div className="flex rounded-xl border border-surface-subtle bg-stone-50 p-1 dark:bg-stone-800/50 lg:hidden">
199
- {/* Graph tab — always accessible; shows animation even before nodes arrive */}
200
  <button
201
  onClick={() => setActiveTab('graph')}
202
  className={cn(
@@ -213,14 +303,12 @@ export function ResultsPage() {
213
  </span>
214
  )}
215
  </button>
216
-
217
- {/* Answer tab — disabled (grey, not clickable) until first SSE event */}
218
  <button
219
  onClick={() => setActiveTab('answer')}
220
- disabled={!hasData}
221
  className={cn(
222
  'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-colors',
223
- !hasData
224
  ? 'cursor-not-allowed text-stone-300 dark:text-stone-600'
225
  : activeTab === 'answer'
226
  ? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
@@ -228,26 +316,23 @@ export function ResultsPage() {
228
  )}
229
  >
230
  Answer
231
- {/* Pulsing dot only once answer has data and is still streaming */}
232
  {hasData && isActive && answerHasContent && (
233
  <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand" />
234
  )}
235
  </button>
236
  </div>
237
 
238
- {/* Two-column grid — collapses to single when graph is hidden */}
239
  <div className={cn(
240
  'flex flex-col gap-6 lg:grid lg:items-start lg:gap-8',
241
  graphCollapsed ? 'lg:grid-cols-1' : 'lg:grid-cols-[1fr_380px]',
242
  )}>
243
 
244
- {/* ── Left: answer column ──────────────────────────────────────── */}
245
  <div className={cn(
246
- 'flex flex-col gap-6',
247
  activeTab === 'graph' && 'hidden lg:flex',
248
  )}>
249
-
250
- {/* Show-graph chip — visible on desktop when graph panel is collapsed */}
251
  {graphCollapsed && (
252
  <button
253
  onClick={toggleGraphCollapsed}
@@ -257,88 +342,98 @@ export function ResultsPage() {
257
  </button>
258
  )}
259
 
260
- {/* Thinking indicator before any SSE event arrives */}
261
- {isActive && !hasData && (
262
- <div className="flex items-center gap-2 text-sm text-stone-400">
263
- <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-500" />
264
- Thinking…
 
265
  </div>
266
- )}
267
-
268
- {/* Agent progress badges */}
269
- {plan.length > 0 && (
270
- <AgentBadges plan={plan} statuses={agentStatuses} />
271
- )}
272
-
273
- {/* Streaming answer */}
274
- {answerText && <Answer text={answerText} />}
275
-
276
- {/* Complete with no answer */}
277
- {isComplete && !answerText && <NoResultsState query={currentQuery} />}
278
-
279
- {/* Guardrail escalation */}
280
- {guardrail?.escalate && <HallucinationWarning />}
281
-
282
- {/* Hard error — no answer at all */}
283
- {isError && !answerText && (
284
- <TimeoutError
285
- message={error ?? undefined}
286
- onRetry={() => runQuery(currentQuery)}
287
- />
288
- )}
289
-
290
- {/* Soft error — answer cut short mid-stream */}
291
- {isError && answerText && error?.includes('incomplete') && (
292
- <div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
293
- <span aria-hidden>⚠</span>
294
- Answer may be incomplete — connection dropped mid-stream.
295
- <button
296
- onClick={() => runQuery(currentQuery)}
297
- className="ml-auto shrink-0 underline"
298
- >
299
- Retry
300
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  </div>
302
  )}
303
 
304
- {/* Citations */}
305
- {citations.length > 0 && <Citations chunks={citations} />}
306
 
307
- {/* Related docs (overflow citations) */}
308
- {isComplete && citations.length > 3 && (
309
- <RelatedDocs chunks={citations.slice(3)} />
310
- )}
311
-
312
- {/* Footer actions */}
313
- {isComplete && (
314
- <div className="flex items-center justify-between gap-4">
315
- <QueryFeedback queryId={queryId} />
316
- <button
317
- onClick={() => setShareOpen(true)}
318
- className="shrink-0 text-xs text-stone-400 underline hover:text-stone-600"
319
- >
320
- Share
321
- </button>
322
- </div>
323
  )}
324
-
325
- {isComplete && <FollowUp onSubmit={runQuery} />}
326
  </div>
327
 
328
  {/* ── Right: graph panel ───────────────────────────────────────── */}
329
- {/* On mobile: controlled by activeTab. On desktop: hidden when graphCollapsed */}
330
  <div className={cn(
331
  'flex flex-col overflow-hidden rounded-xl border border-surface-subtle',
332
- // Mobile: toggle by tab; desktop: toggle by collapsed state
333
  activeTab === 'answer' ? 'hidden lg:flex' : 'flex',
334
  graphCollapsed && 'lg:hidden',
335
- // Maximized: fixed fullscreen overlay
336
  graphMaximized
337
  ? 'fixed inset-0 z-50 rounded-none border-0 bg-white dark:bg-stone-950'
338
  : 'h-[440px]',
339
  )}>
340
-
341
- {/* Graph toolbar */}
342
  <div className="flex shrink-0 items-center gap-2 border-b border-surface-subtle px-3 py-2">
343
  <span className="flex-1 text-xs font-semibold uppercase tracking-wide text-stone-400">
344
  Knowledge Graph
@@ -348,7 +443,6 @@ export function ResultsPage() {
348
  {graphNodes.length} nodes · {graphEdges.length} edges
349
  </span>
350
  )}
351
- {/* Maximize / restore */}
352
  <button
353
  onClick={() => setGraphMaximized((m) => !m)}
354
  title={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
@@ -357,7 +451,6 @@ export function ResultsPage() {
357
  >
358
  {graphMaximized ? '⤡' : '⤢'}
359
  </button>
360
- {/* Collapse — desktop only; on mobile graph visibility is via tabs */}
361
  <button
362
  onClick={toggleGraphCollapsed}
363
  title="Collapse graph"
@@ -368,7 +461,6 @@ export function ResultsPage() {
368
  </button>
369
  </div>
370
 
371
- {/* Canvas — flex-1 fills remaining panel height */}
372
  <KnowledgeGraph
373
  nodes={graphNodes}
374
  edges={graphEdges}
@@ -378,7 +470,6 @@ export function ResultsPage() {
378
  className="flex-1"
379
  />
380
 
381
- {/* Graph footer */}
382
  <div className="flex shrink-0 items-center gap-2 border-t border-surface-subtle px-3 py-2">
383
  {gState === 'done' && (
384
  <button
@@ -392,7 +483,6 @@ export function ResultsPage() {
392
  {gState === 'retrying' && <NetworkRetry attempt={retryCount + 1} />}
393
  </div>
394
 
395
- {/* Max-retries error bar */}
396
  {gState === 'error' && (
397
  <div className="flex shrink-0 items-center justify-between border-t border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-700 dark:bg-stone-800/40">
398
  <span className="text-stone-500 dark:text-stone-400">
@@ -411,17 +501,14 @@ export function ResultsPage() {
411
  </>
412
  )}
413
 
414
- {/* Tooltip — document-level fixed positioning */}
415
  <GraphNodeTooltip node={hoveredNode} x={hoverPos.x} y={hoverPos.y} />
416
 
417
- {/* Share modal */}
418
  <ShareResults
419
  query={currentQuery}
420
  open={shareOpen}
421
  onClose={() => setShareOpen(false)}
422
  />
423
 
424
- {/* Node detail slide-in panel */}
425
  <GraphNodeDetailPanel
426
  node={selectedNode}
427
  teamId={user?.team_id ?? 'default'}
 
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { useSearch, useNavigate } from '@tanstack/react-router'
3
  import { useAuthStore } from '@/stores/authStore'
4
  import { useUIStore } from '@/stores/uiStore'
5
  import { useSSEStream } from '@/hooks/useSSEStream'
 
27
 
28
  type Tab = 'graph' | 'answer'
29
 
30
+ const QR_PREFIX = 'gs_qr_'
31
+
32
+ interface Exchange {
33
+ id: string
34
+ query: string
35
+ answer: string
36
+ citations: RetrievedChunk[]
37
+ plan: AgentTask[]
38
+ }
39
+
40
  export function ResultsPage() {
41
  const user = useAuthStore((s) => s.user)
42
+ const navigate = useNavigate()
43
  const { graphCollapsed, toggleGraphCollapsed } = useUIStore()
44
  const sessionRef = useRef(crypto.randomUUID())
45
+ const savedRef = useRef(false)
46
+ const { q: initialQuery, qid: initialQid, fresh: forceRun } = useSearch({ from: '/query' })
47
 
48
+ // ── Conversation history ─────────────────────────────────────────────────────
49
+ const [exchanges, setExchanges] = useState<Exchange[]>([])
50
+
51
+ // ── SSE state (current streaming exchange) ───────────────────────────────────
52
  const [plan, setPlan] = useState<AgentTask[]>([])
53
  const [agentStatuses, setStatuses] = useState<Record<string, AgentStatus>>({})
54
  const [answerText, setAnswerText] = useState('')
 
58
  const [queryId, setQueryId] = useState('')
59
  const [shareOpen, setShareOpen] = useState(false)
60
 
61
+ // Refs to read current values inside the completion effect without stale closure
62
+ const answerTextRef = useRef('')
63
+ const citationsRef = useRef<RetrievedChunk[]>([])
64
+ const planRef = useRef<AgentTask[]>([])
65
+ const currentQueryRef = useRef('')
66
+
67
+ // Keep refs in sync
68
+ useEffect(() => { answerTextRef.current = answerText }, [answerText])
69
+ useEffect(() => { citationsRef.current = citations }, [citations])
70
+ useEffect(() => { planRef.current = plan }, [plan])
71
+ useEffect(() => { currentQueryRef.current = currentQuery }, [currentQuery])
72
+
73
  // ── Graph state ─────────────────────────────────────────────────────────────
74
  const [graphNodes, setGraphNodes] = useState<GraphNode[]>([])
75
  const [graphEdges, setGraphEdges] = useState<GraphEdge[]>([])
76
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null)
77
  const [hoverPos, setHoverPos] = useState({ x: 0, y: 0 })
78
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
 
79
  const nodesAccRef = useRef<GraphNode[]>([])
80
  const edgesAccRef = useRef<GraphEdge[]>([])
81
 
82
  // ── Mobile tab ──────────────────────────────────────────────────────────────
83
  const [activeTab, setActiveTab] = useState<Tab>('graph')
 
84
  const [graphMaximized, setGraphMaximized] = useState(false)
85
 
86
  // ── Hooks ───────────────────────────────────────────────────────────────────
87
  const { state, error, firstEventArrived, stream } = useSSEStream()
88
  const { gState, retryCount, connect, disconnect } = useGraphStream()
89
 
90
+ useEffect(() => {
91
+ if (gState === 'error' && !graphCollapsed) toggleGraphCollapsed()
92
+ }, [gState]) // eslint-disable-line react-hooks/exhaustive-deps
93
+
94
  const reconnectGraph = useCallback(() => {
95
  nodesAccRef.current = []
96
  edgesAccRef.current = []
 
114
  // ── Query runner ─────────────────────────────────────────────────────────────
115
  const runQuery = useCallback(
116
  async (query: string) => {
117
+ // Save the current completed exchange to history before resetting
118
+ if (answerTextRef.current && currentQueryRef.current) {
119
+ setExchanges((prev) => [
120
+ ...prev,
121
+ {
122
+ id: crypto.randomUUID(),
123
+ query: currentQueryRef.current,
124
+ answer: answerTextRef.current,
125
+ citations: citationsRef.current,
126
+ plan: planRef.current,
127
+ },
128
+ ])
129
+ }
130
+
131
+ savedRef.current = false
132
  setPlan([])
133
  setStatuses({})
134
  setAnswerText('')
 
137
  setCurrentQuery(query)
138
  setQueryId(crypto.randomUUID())
139
  setShareOpen(false)
140
+ setActiveTab('graph')
141
  setSelectedNode(null)
142
 
143
  reconnectGraph()
 
193
  setSelectedNode(node)
194
  }, [])
195
 
 
196
  const runQueryRef = useRef(runQuery)
197
  runQueryRef.current = runQuery
198
+ const textKey = (q: string) => QR_PREFIX + 'q:' + q
199
+
200
+ const restoreFromCache = (raw: string): boolean => {
201
+ try {
202
+ const { query, answer, citations: cc } = JSON.parse(raw)
203
+ setCurrentQuery(query)
204
+ setAnswerText(answer)
205
+ setCitations(cc ?? [])
206
+ savedRef.current = true
207
+ return true
208
+ } catch { return false }
209
+ }
210
+
211
+ const lastParamKeyRef = useRef('')
212
+
213
  useEffect(() => {
214
+ const paramKey = `${initialQid ?? ''}|${initialQuery ?? ''}|${String(forceRun)}`
215
+ if (lastParamKeyRef.current === paramKey) return
216
+ lastParamKeyRef.current = paramKey
217
+
218
+ if (!forceRun) {
219
+ if (initialQid) {
220
+ try {
221
+ const raw = sessionStorage.getItem(QR_PREFIX + initialQid)
222
+ if (raw && restoreFromCache(raw)) return
223
+ } catch { /* ignore */ }
224
+ }
225
+ if (initialQuery) {
226
+ try {
227
+ const raw = sessionStorage.getItem(textKey(initialQuery))
228
+ if (raw && restoreFromCache(raw)) return
229
+ } catch { /* ignore */ }
230
+ }
231
+ }
232
  if (initialQuery) runQueryRef.current(initialQuery)
233
  // eslint-disable-next-line react-hooks/exhaustive-deps
234
+ }, [initialQuery, initialQid, forceRun])
235
+
236
+ // Cache completed result and update URL to ?qid=
237
+ useEffect(() => {
238
+ if (state !== 'complete' || !currentQuery || !answerText || savedRef.current) return
239
+ savedRef.current = true
240
+ const qid = sessionRef.current
241
+ const payload = JSON.stringify({ query: currentQuery, answer: answerText, citations })
242
+ try {
243
+ sessionStorage.setItem(QR_PREFIX + qid, payload)
244
+ sessionStorage.setItem(textKey(currentQuery), payload)
245
+ } catch { /* storage full */ }
246
+ navigate({ to: '/query', search: { qid, q: undefined, fresh: false }, replace: true })
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, [state])
249
+
250
+ // Scroll to bottom when new exchange added or answer streams in
251
+ const bottomRef = useRef<HTMLDivElement>(null)
252
+ useEffect(() => {
253
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
254
+ }, [exchanges.length, answerText])
255
 
256
  // ── Derived ─────────────────────────────────────────────────────────────────
257
  const isLoading = state === 'loading'
 
259
  const isComplete = state === 'complete'
260
  const isError = state === 'error'
261
  const isActive = isLoading || isStreaming
262
+ const hasData = firstEventArrived.current
263
 
 
264
  const graphHasContent = graphNodes.length > 0
265
  const answerHasContent = !!answerText || plan.length > 0
266
 
267
  return (
268
  <div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-8">
269
 
270
+ {/* Search bar */}
271
  <SearchBox
272
+ key={currentQuery || initialQuery}
273
  onSubmit={runQuery}
274
  disabled={isActive}
275
+ defaultValue={currentQuery || (initialQuery ?? '')}
276
  />
277
 
278
+ {/* Idle home state */}
279
+ {state === 'idle' && !currentQuery && exchanges.length === 0 && (
280
  <div className="flex flex-1 items-center justify-center">
281
  <p className="text-sm text-stone-400">Ask a question to get started.</p>
282
  </div>
283
  )}
284
 
285
+ {/* ── Results layout ────────────────────��────────────────────────────── */}
286
+ {(currentQuery || exchanges.length > 0) && (
287
  <>
288
+ {/* Mobile tab bar */}
 
289
  <div className="flex rounded-xl border border-surface-subtle bg-stone-50 p-1 dark:bg-stone-800/50 lg:hidden">
 
290
  <button
291
  onClick={() => setActiveTab('graph')}
292
  className={cn(
 
303
  </span>
304
  )}
305
  </button>
 
 
306
  <button
307
  onClick={() => setActiveTab('answer')}
308
+ disabled={!hasData && exchanges.length === 0}
309
  className={cn(
310
  'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-colors',
311
+ !hasData && exchanges.length === 0
312
  ? 'cursor-not-allowed text-stone-300 dark:text-stone-600'
313
  : activeTab === 'answer'
314
  ? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
 
316
  )}
317
  >
318
  Answer
 
319
  {hasData && isActive && answerHasContent && (
320
  <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand" />
321
  )}
322
  </button>
323
  </div>
324
 
325
+ {/* Two-column grid */}
326
  <div className={cn(
327
  'flex flex-col gap-6 lg:grid lg:items-start lg:gap-8',
328
  graphCollapsed ? 'lg:grid-cols-1' : 'lg:grid-cols-[1fr_380px]',
329
  )}>
330
 
331
+ {/* ── Left: conversation column ─────────────────────────────────── */}
332
  <div className={cn(
333
+ 'flex flex-col gap-8',
334
  activeTab === 'graph' && 'hidden lg:flex',
335
  )}>
 
 
336
  {graphCollapsed && (
337
  <button
338
  onClick={toggleGraphCollapsed}
 
342
  </button>
343
  )}
344
 
345
+ {/* ── Completed past exchanges ─────────────────────────────── */}
346
+ {exchanges.map((ex) => (
347
+ <div key={ex.id} className="flex flex-col gap-4 border-b border-surface-subtle pb-8">
348
+ <p className="text-sm font-semibold text-stone-500">{ex.query}</p>
349
+ <Answer text={ex.answer} />
350
+ {ex.citations.length > 0 && <Citations chunks={ex.citations} />}
351
  </div>
352
+ ))}
353
+
354
+ {/* ── Current streaming exchange ────────────────────────────── */}
355
+ {currentQuery && (
356
+ <div className="flex flex-col gap-4">
357
+ {/* Show query heading only when there are prior exchanges */}
358
+ {exchanges.length > 0 && (
359
+ <p className="text-sm font-semibold text-stone-700 dark:text-stone-300">
360
+ {currentQuery}
361
+ </p>
362
+ )}
363
+
364
+ {isActive && !hasData && (
365
+ <div className="flex items-center gap-2 text-sm text-stone-400">
366
+ <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-500" />
367
+ Thinking…
368
+ </div>
369
+ )}
370
+
371
+ {plan.length > 0 && (
372
+ <AgentBadges plan={plan} statuses={agentStatuses} />
373
+ )}
374
+
375
+ {answerText && <Answer text={answerText} />}
376
+
377
+ {isComplete && !answerText && citations.length === 0 && (
378
+ <NoResultsState query={currentQuery} />
379
+ )}
380
+
381
+ {guardrail?.escalate && <HallucinationWarning />}
382
+
383
+ {isError && !answerText && (
384
+ <TimeoutError
385
+ message={error ?? undefined}
386
+ onRetry={() => runQuery(currentQuery)}
387
+ />
388
+ )}
389
+
390
+ {isError && answerText && error?.includes('incomplete') && (
391
+ <div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
392
+ <span aria-hidden>⚠</span>
393
+ Answer may be incomplete — connection dropped mid-stream.
394
+ <button onClick={() => runQuery(currentQuery)} className="ml-auto shrink-0 underline">
395
+ Retry
396
+ </button>
397
+ </div>
398
+ )}
399
+
400
+ {citations.length > 0 && <Citations chunks={citations} />}
401
+
402
+ {isComplete && citations.length > 3 && (
403
+ <RelatedDocs chunks={citations.slice(3)} />
404
+ )}
405
+
406
+ {isComplete && (
407
+ <div className="flex items-center justify-between gap-4">
408
+ <QueryFeedback queryId={queryId} />
409
+ <button
410
+ onClick={() => setShareOpen(true)}
411
+ className="shrink-0 text-xs text-stone-400 underline hover:text-stone-600"
412
+ >
413
+ Share
414
+ </button>
415
+ </div>
416
+ )}
417
  </div>
418
  )}
419
 
420
+ <div ref={bottomRef} />
 
421
 
422
+ {/* Follow-up always visible at bottom once there's any content */}
423
+ {(isComplete || exchanges.length > 0) && (
424
+ <FollowUp onSubmit={runQuery} disabled={isActive} />
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  )}
 
 
426
  </div>
427
 
428
  {/* ── Right: graph panel ───────────────────────────────────────── */}
 
429
  <div className={cn(
430
  'flex flex-col overflow-hidden rounded-xl border border-surface-subtle',
 
431
  activeTab === 'answer' ? 'hidden lg:flex' : 'flex',
432
  graphCollapsed && 'lg:hidden',
 
433
  graphMaximized
434
  ? 'fixed inset-0 z-50 rounded-none border-0 bg-white dark:bg-stone-950'
435
  : 'h-[440px]',
436
  )}>
 
 
437
  <div className="flex shrink-0 items-center gap-2 border-b border-surface-subtle px-3 py-2">
438
  <span className="flex-1 text-xs font-semibold uppercase tracking-wide text-stone-400">
439
  Knowledge Graph
 
443
  {graphNodes.length} nodes · {graphEdges.length} edges
444
  </span>
445
  )}
 
446
  <button
447
  onClick={() => setGraphMaximized((m) => !m)}
448
  title={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
 
451
  >
452
  {graphMaximized ? '⤡' : '⤢'}
453
  </button>
 
454
  <button
455
  onClick={toggleGraphCollapsed}
456
  title="Collapse graph"
 
461
  </button>
462
  </div>
463
 
 
464
  <KnowledgeGraph
465
  nodes={graphNodes}
466
  edges={graphEdges}
 
470
  className="flex-1"
471
  />
472
 
 
473
  <div className="flex shrink-0 items-center gap-2 border-t border-surface-subtle px-3 py-2">
474
  {gState === 'done' && (
475
  <button
 
483
  {gState === 'retrying' && <NetworkRetry attempt={retryCount + 1} />}
484
  </div>
485
 
 
486
  {gState === 'error' && (
487
  <div className="flex shrink-0 items-center justify-between border-t border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-700 dark:bg-stone-800/40">
488
  <span className="text-stone-500 dark:text-stone-400">
 
501
  </>
502
  )}
503
 
 
504
  <GraphNodeTooltip node={hoveredNode} x={hoverPos.x} y={hoverPos.y} />
505
 
 
506
  <ShareResults
507
  query={currentQuery}
508
  open={shareOpen}
509
  onClose={() => setShareOpen(false)}
510
  />
511
 
 
512
  <GraphNodeDetailPanel
513
  node={selectedNode}
514
  teamId={user?.team_id ?? 'default'}
frontend/src/components/workspace/QueryHistory.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
2
  import { useQuery } from '@tanstack/react-query'
3
  import { apiFetch } from '@/lib/http'
4
  import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'
@@ -25,11 +25,13 @@ async function fetchHistory(page: number): Promise<HistoryResponse> {
25
 
26
  interface Props {
27
  onReplay?: (query: string) => void
 
28
  }
29
 
30
- export function QueryHistory({ onReplay }: Props) {
31
  const [page, setPage] = useState(1)
32
- const [expanded, setExpanded] = useState<string | null>(null)
 
33
 
34
  const { data, isLoading } = useQuery({
35
  queryKey: ['workspace-history', page],
@@ -37,6 +39,13 @@ export function QueryHistory({ onReplay }: Props) {
37
  staleTime: 60_000,
38
  })
39
 
 
 
 
 
 
 
 
40
  const totalPages = data ? Math.ceil(data.total / 20) : 1
41
 
42
  return (
@@ -61,7 +70,11 @@ export function QueryHistory({ onReplay }: Props) {
61
  {(data?.items ?? []).map((item) => (
62
  <div
63
  key={item.id}
64
- className="rounded-xl border border-surface-subtle transition-colors hover:border-stone-300 dark:hover:border-stone-600"
 
 
 
 
65
  >
66
  <button
67
  className="flex w-full items-start gap-3 p-4 text-left"
 
1
+ import { useState, useEffect, useRef } from 'react'
2
  import { useQuery } from '@tanstack/react-query'
3
  import { apiFetch } from '@/lib/http'
4
  import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'
 
25
 
26
  interface Props {
27
  onReplay?: (query: string) => void
28
+ focusId?: string
29
  }
30
 
31
+ export function QueryHistory({ onReplay, focusId }: Props) {
32
  const [page, setPage] = useState(1)
33
+ const [expanded, setExpanded] = useState<string | null>(focusId ?? null)
34
+ const focusRef = useRef<HTMLDivElement>(null)
35
 
36
  const { data, isLoading } = useQuery({
37
  queryKey: ['workspace-history', page],
 
39
  staleTime: 60_000,
40
  })
41
 
42
+ // Scroll the focused item into view once data loads
43
+ useEffect(() => {
44
+ if (focusId && focusRef.current) {
45
+ focusRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
46
+ }
47
+ }, [focusId, data])
48
+
49
  const totalPages = data ? Math.ceil(data.total / 20) : 1
50
 
51
  return (
 
70
  {(data?.items ?? []).map((item) => (
71
  <div
72
  key={item.id}
73
+ ref={item.id === focusId ? focusRef : undefined}
74
+ className={cn(
75
+ 'rounded-xl border border-surface-subtle transition-colors hover:border-stone-300 dark:hover:border-stone-600',
76
+ item.id === focusId && 'border-brand/50 dark:border-brand/40',
77
+ )}
78
  >
79
  <button
80
  className="flex w-full items-start gap-3 p-4 text-left"
frontend/src/config/env.ts CHANGED
@@ -1,11 +1,14 @@
1
- const get = (key: string): string => {
2
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
3
- const val = (import.meta as any).env[key]
4
- if (!val) throw new Error(`Missing env var: ${key}`)
5
- return val
6
- }
7
 
8
  export const env = {
9
- apiBaseUrl: get('VITE_API_BASE_URL'),
10
- wsBaseUrl: get('VITE_WS_BASE_URL'),
11
- } as const
 
 
 
 
 
 
 
 
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ const _env = (import.meta as any).env
 
 
 
 
3
 
4
  export const env = {
5
+ // Empty string → relative URLs → works when frontend is served from FastAPI via ngrok
6
+ apiBaseUrl: _env.VITE_API_BASE_URL || '',
7
+
8
+ // Derived at runtime from page host so WebSocket works on any ngrok URL automatically
9
+ get wsBaseUrl(): string {
10
+ if (_env.VITE_WS_BASE_URL) return _env.VITE_WS_BASE_URL
11
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
12
+ return `${proto}//${window.location.host}`
13
+ },
14
+ }
frontend/src/config/routes.ts CHANGED
@@ -65,7 +65,9 @@ export const queryRoute = createRoute({
65
  beforeLoad: requireAuth,
66
  component: QueryPage,
67
  validateSearch: (s: Record<string, unknown>) => ({
68
- q: typeof s.q === 'string' ? s.q : undefined,
 
 
69
  }),
70
  })
71
 
@@ -88,6 +90,9 @@ export const workspaceRoute = createRoute({
88
  path: '/workspace',
89
  beforeLoad: requireAuth,
90
  component: Workspace,
 
 
 
91
  })
92
 
93
  export const settingsRoute = createRoute({
 
65
  beforeLoad: requireAuth,
66
  component: QueryPage,
67
  validateSearch: (s: Record<string, unknown>) => ({
68
+ q: typeof s.q === 'string' ? s.q : undefined,
69
+ qid: typeof s.qid === 'string' ? s.qid : undefined,
70
+ fresh: s.fresh === true || s.fresh === 'true',
71
  }),
72
  })
73
 
 
90
  path: '/workspace',
91
  beforeLoad: requireAuth,
92
  component: Workspace,
93
+ validateSearch: (s: Record<string, unknown>) => ({
94
+ id: typeof s.id === 'string' ? s.id : undefined,
95
+ }),
96
  })
97
 
98
  export const settingsRoute = createRoute({
frontend/src/hooks/useGraphStream.ts CHANGED
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
2
  import { env } from '@/config/env'
3
  import type { GraphNode, GraphEdge, GraphDoneEvent } from '@/types/api'
4
 
5
- const MAX_RETRIES = 5
6
  const BASE_DELAY_MS = 1000
7
 
8
  type Callbacks = {
@@ -49,6 +49,11 @@ export function useGraphStream() {
49
  activeRef.current = false
50
  setGState('done')
51
  callbacksRef.current?.onDone(msg as GraphDoneEvent)
 
 
 
 
 
52
  }
53
  } catch {
54
  // Non-JSON frame — ignore
 
2
  import { env } from '@/config/env'
3
  import type { GraphNode, GraphEdge, GraphDoneEvent } from '@/types/api'
4
 
5
+ const MAX_RETRIES = 1
6
  const BASE_DELAY_MS = 1000
7
 
8
  type Callbacks = {
 
49
  activeRef.current = false
50
  setGState('done')
51
  callbacksRef.current?.onDone(msg as GraphDoneEvent)
52
+ } else if (msg.event === 'error') {
53
+ // Server signalled a terminal error — stop retrying
54
+ activeRef.current = false
55
+ setGState('error')
56
+ callbacksRef.current?.onError(msg.message ?? 'Graph unavailable')
57
  }
58
  } catch {
59
  // Non-JSON frame — ignore
frontend/src/hooks/useNotifications.ts CHANGED
@@ -21,9 +21,11 @@ export function useNotifications() {
21
 
22
  ws.onmessage = (evt) => {
23
  try {
24
- const msg = JSON.parse(evt.data as string) as Omit<AppNotification, 'id' | 'read'>
 
 
25
  const notification: AppNotification = {
26
- ...msg,
27
  id: crypto.randomUUID(),
28
  read: false,
29
  }
 
21
 
22
  ws.onmessage = (evt) => {
23
  try {
24
+ const msg = JSON.parse(evt.data as string)
25
+ // Skip keepalive pings and any frame without a real message
26
+ if (!msg.message || msg.type === 'ping') return
27
  const notification: AppNotification = {
28
+ ...(msg as Omit<AppNotification, 'id' | 'read'>),
29
  id: crypto.randomUUID(),
30
  read: false,
31
  }
frontend/src/pages/AcceptInvitePage.tsx CHANGED
@@ -93,7 +93,7 @@ export default function AcceptInvitePage() {
93
  {...register('password')}
94
  type="password"
95
  autoComplete="new-password"
96
- className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
97
  />
98
  {errors.password && <span className="text-xs text-red-600">{errors.password.message}</span>}
99
  </label>
@@ -104,7 +104,7 @@ export default function AcceptInvitePage() {
104
  {...register('confirm')}
105
  type="password"
106
  autoComplete="new-password"
107
- className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
108
  />
109
  {errors.confirm && <span className="text-xs text-red-600">{errors.confirm.message}</span>}
110
  </label>
 
93
  {...register('password')}
94
  type="password"
95
  autoComplete="new-password"
96
+ className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
97
  />
98
  {errors.password && <span className="text-xs text-red-600">{errors.password.message}</span>}
99
  </label>
 
104
  {...register('confirm')}
105
  type="password"
106
  autoComplete="new-password"
107
+ className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
108
  />
109
  {errors.confirm && <span className="text-xs text-red-600">{errors.confirm.message}</span>}
110
  </label>
frontend/src/pages/Home.tsx CHANGED
@@ -6,7 +6,7 @@ export default function Home() {
6
  const navigate = useNavigate()
7
 
8
  const handleQuery = (query: string) => {
9
- navigate({ to: '/query', search: { q: query } })
10
  }
11
 
12
  return (
 
6
  const navigate = useNavigate()
7
 
8
  const handleQuery = (query: string) => {
9
+ navigate({ to: '/query', search: { q: query, qid: undefined, fresh: false } })
10
  }
11
 
12
  return (
frontend/src/pages/LoginPage.tsx CHANGED
@@ -44,7 +44,7 @@ export default function LoginPage() {
44
  {...register('email')}
45
  type="email"
46
  autoComplete="email"
47
- className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
48
  />
49
  {errors.email && <span className="text-red-600 text-xs">{errors.email.message}</span>}
50
  </label>
@@ -55,7 +55,7 @@ export default function LoginPage() {
55
  {...register('password')}
56
  type="password"
57
  autoComplete="current-password"
58
- className="rounded border border-surface-subtle px-3 py-2 focus:outline-brand"
59
  />
60
  {errors.password && <span className="text-red-600 text-xs">{errors.password.message}</span>}
61
  </label>
 
44
  {...register('email')}
45
  type="email"
46
  autoComplete="email"
47
+ className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
48
  />
49
  {errors.email && <span className="text-red-600 text-xs">{errors.email.message}</span>}
50
  </label>
 
55
  {...register('password')}
56
  type="password"
57
  autoComplete="current-password"
58
+ className="rounded border border-surface-subtle bg-white px-3 py-2 text-stone-900 placeholder-stone-400 focus:outline-brand dark:border-stone-600 dark:bg-stone-800 dark:text-white"
59
  />
60
  {errors.password && <span className="text-red-600 text-xs">{errors.password.message}</span>}
61
  </label>
frontend/src/pages/OAuthCallbackPage.tsx CHANGED
@@ -15,7 +15,11 @@ export default function OAuthCallbackPage() {
15
  const hasError = params.get('error') !== null
16
 
17
  if (hasError) {
18
- addToast({ type: 'error', message: 'Google sign-in failed. Please try again.' })
 
 
 
 
19
  navigate({ to: '/login' })
20
  return
21
  }
 
15
  const hasError = params.get('error') !== null
16
 
17
  if (hasError) {
18
+ const error = params.get('error')
19
+ const message = error === 'not_invited'
20
+ ? 'Your Google account is not linked to any workspace. Ask an admin to invite you first.'
21
+ : 'Google sign-in failed. Please try again.'
22
+ addToast({ type: 'error', message })
23
  navigate({ to: '/login' })
24
  return
25
  }
frontend/src/pages/WorkspacePage.tsx CHANGED
@@ -1,17 +1,18 @@
1
- import { useNavigate } from '@tanstack/react-router'
2
  import { QueryHistory } from '@/components/workspace/QueryHistory'
3
 
4
  export default function WorkspacePage() {
5
  const navigate = useNavigate()
 
6
 
7
  const handleReplay = (query: string) => {
8
- navigate({ to: '/query', search: { q: query } })
9
  }
10
 
11
  return (
12
  <div className="mx-auto max-w-3xl px-4 py-8">
13
  <h1 className="mb-6 text-2xl font-semibold">Workspace</h1>
14
- <QueryHistory onReplay={handleReplay} />
15
  </div>
16
  )
17
  }
 
1
+ import { useNavigate, useSearch } from '@tanstack/react-router'
2
  import { QueryHistory } from '@/components/workspace/QueryHistory'
3
 
4
  export default function WorkspacePage() {
5
  const navigate = useNavigate()
6
+ const { id: focusId } = useSearch({ from: '/workspace' })
7
 
8
  const handleReplay = (query: string) => {
9
+ navigate({ to: '/query', search: { q: query, qid: undefined, fresh: true } })
10
  }
11
 
12
  return (
13
  <div className="mx-auto max-w-3xl px-4 py-8">
14
  <h1 className="mb-6 text-2xl font-semibold">Workspace</h1>
15
+ <QueryHistory onReplay={handleReplay} focusId={focusId} />
16
  </div>
17
  )
18
  }
frontend/src/types/api.ts CHANGED
@@ -5,7 +5,7 @@ export interface QueryInput {
5
  }
6
 
7
  export interface AgentTask {
8
- agent: 'doc_search' | 'ticket_lookup' | 'live_docs' | 'summariser'
9
  input: string
10
  depends_on: string[]
11
  }
 
5
  }
6
 
7
  export interface AgentTask {
8
+ agent: 'doc_search' | 'ticket_lookup' | 'confluence_search' | 'slack_search' | 'live_docs' | 'summariser' | 'sql_query'
9
  input: string
10
  depends_on: string[]
11
  }
graph_store/stream.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import logging
 
5
 
6
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
7
  from neo4j import AsyncGraphDatabase
@@ -11,6 +12,31 @@ from graph_store.config import settings
11
  logger = logging.getLogger(__name__)
12
  router = APIRouter(tags=["graph"])
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  # ---------------------------------------------------------------------------
15
  # Cypher queries
16
  # ---------------------------------------------------------------------------
@@ -126,15 +152,25 @@ async def graph_stream(websocket: WebSocket):
126
  else:
127
  logger.info("graph_stream: no channel restriction — serving full graph")
128
 
 
 
 
 
 
 
 
 
 
 
 
129
  driver = AsyncGraphDatabase.driver(
130
  settings.neo4j_uri,
131
  auth=(settings.neo4j_username, settings.neo4j_password),
132
  max_connection_lifetime=300,
133
- connection_acquisition_timeout=60,
134
  keep_alive=True,
135
  )
136
  try:
137
- # Fetch all records at once (same pattern as traverse — avoids async-for issues)
138
  async with driver.session(database=settings.neo4j_database) as session:
139
  if use_filtered:
140
  result = await session.run(
@@ -190,10 +226,10 @@ async def graph_stream(websocket: WebSocket):
190
 
191
  except WebSocketDisconnect:
192
  logger.info("graph_stream: client disconnected")
193
- except Exception:
194
- logger.exception("graph_stream: error")
195
  try:
196
- await websocket.send_json({"event": "error", "message": "Graph stream failed"})
197
  except Exception:
198
  pass
199
  finally:
 
2
 
3
  import asyncio
4
  import logging
5
+ import time
6
 
7
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
  from neo4j import AsyncGraphDatabase
 
12
  logger = logging.getLogger(__name__)
13
  router = APIRouter(tags=["graph"])
14
 
15
+ # After a DNS/connection failure, skip Neo4j for this many seconds before retrying
16
+ _NEO4J_COOLDOWN_S = 120
17
+ _neo4j_failed_at: float | None = None
18
+
19
+
20
+ def _neo4j_is_available() -> bool:
21
+ global _neo4j_failed_at
22
+ if _neo4j_failed_at is None:
23
+ return True
24
+ if time.monotonic() - _neo4j_failed_at > _NEO4J_COOLDOWN_S:
25
+ _neo4j_failed_at = None
26
+ return True
27
+ return False
28
+
29
+
30
+ def _mark_neo4j_failed() -> None:
31
+ global _neo4j_failed_at
32
+ if _neo4j_failed_at is None:
33
+ logger.warning(
34
+ "graph_stream: Neo4j unreachable (%s) — graph panel disabled for %ds",
35
+ settings.neo4j_uri,
36
+ _NEO4J_COOLDOWN_S,
37
+ )
38
+ _neo4j_failed_at = time.monotonic()
39
+
40
  # ---------------------------------------------------------------------------
41
  # Cypher queries
42
  # ---------------------------------------------------------------------------
 
152
  else:
153
  logger.info("graph_stream: no channel restriction — serving full graph")
154
 
155
+ if not _neo4j_is_available():
156
+ try:
157
+ await websocket.send_json({"event": "error", "message": "Knowledge graph unavailable"})
158
+ except Exception:
159
+ pass
160
+ try:
161
+ await websocket.close()
162
+ except Exception:
163
+ pass
164
+ return
165
+
166
  driver = AsyncGraphDatabase.driver(
167
  settings.neo4j_uri,
168
  auth=(settings.neo4j_username, settings.neo4j_password),
169
  max_connection_lifetime=300,
170
+ connection_acquisition_timeout=10,
171
  keep_alive=True,
172
  )
173
  try:
 
174
  async with driver.session(database=settings.neo4j_database) as session:
175
  if use_filtered:
176
  result = await session.run(
 
226
 
227
  except WebSocketDisconnect:
228
  logger.info("graph_stream: client disconnected")
229
+ except Exception as exc:
230
+ _mark_neo4j_failed()
231
  try:
232
+ await websocket.send_json({"event": "error", "message": "Knowledge graph unavailable"})
233
  except Exception:
234
  pass
235
  finally:
ingestion/jobs/ingest_job.py CHANGED
@@ -16,7 +16,11 @@ _SOURCE_REGISTRY: dict[str, Any] = {}
16
 
17
 
18
  async def _run_graph_pipeline(embedded, doc) -> None:
19
- from graph_store.config import settings as graph_settings
 
 
 
 
20
 
21
  if not graph_settings.neo4j_uri:
22
  logger.warning("ingest_job: NEO4J_URI not set — skipping graph pipeline")
@@ -126,10 +130,10 @@ async def _run_ingest_async(job_id: str, payload: IngestSourcePayload) -> dict[s
126
  for chunk in chunks:
127
  chunk.text = mask_pii(chunk.text)
128
 
 
129
  embedded = embed_chunks(chunks)
130
  qdrant_upsert(embedded)
131
  sb_upsert_chunks(chunks, client=sb)
132
- upsert_document(doc, client=sb)
133
  total_chunks += len(chunks)
134
  logger.info("ingest_job: ingested %d chunks for doc_id=%s", len(chunks), doc.doc_id)
135
 
 
16
 
17
 
18
  async def _run_graph_pipeline(embedded, doc) -> None:
19
+ try:
20
+ from graph_store.config import settings as graph_settings
21
+ except Exception:
22
+ logger.warning("ingest_job: graph_store not available — skipping graph pipeline")
23
+ return
24
 
25
  if not graph_settings.neo4j_uri:
26
  logger.warning("ingest_job: NEO4J_URI not set — skipping graph pipeline")
 
130
  for chunk in chunks:
131
  chunk.text = mask_pii(chunk.text)
132
 
133
+ upsert_document(doc, client=sb)
134
  embedded = embed_chunks(chunks)
135
  qdrant_upsert(embedded)
136
  sb_upsert_chunks(chunks, client=sb)
 
137
  total_chunks += len(chunks)
138
  logger.info("ingest_job: ingested %d chunks for doc_id=%s", len(chunks), doc.doc_id)
139
 
ingestion/sources/github.py CHANGED
@@ -37,7 +37,7 @@ class GithubSource(BaseSource):
37
  self._team_id = team_id
38
  self._repo_url = repo_url
39
  self._supabase = supabase_client
40
- self._path_filter = path_filter or settings.github_path_filter
41
  self._branch = branch or settings.github_branch
42
  self._token = token or settings.github_token
43
  self._owner, self._repo = _parse_owner_repo(repo_url)
@@ -125,12 +125,15 @@ class GithubSource(BaseSource):
125
  logger.exception("github: failed to fetch file tree")
126
  return []
127
 
 
 
128
  md_paths = [
129
  item["path"]
130
  for item in tree
131
  if item["type"] == "blob"
132
- and item["path"].endswith(".md")
133
- and item["path"].startswith(self._path_filter)
 
134
  ]
135
 
136
  docs: list[RawDocument] = []
@@ -151,7 +154,7 @@ class GithubSource(BaseSource):
151
  source_url = data.get("html_url", f"{self._repo_url}/blob/{self._branch}/{path}")
152
  return RawDocument(
153
  doc_id=doc_id,
154
- title=path.split("/")[-1].removesuffix(".md"),
155
  content=content,
156
  source_url=source_url,
157
  source_type="github",
 
37
  self._team_id = team_id
38
  self._repo_url = repo_url
39
  self._supabase = supabase_client
40
+ self._path_filter = path_filter # empty string = no filter; caller sets explicitly
41
  self._branch = branch or settings.github_branch
42
  self._token = token or settings.github_token
43
  self._owner, self._repo = _parse_owner_repo(repo_url)
 
125
  logger.exception("github: failed to fetch file tree")
126
  return []
127
 
128
+ INDEXABLE_EXTENSIONS = {".md", ".txt", ".rst", ".py", ".ts", ".tsx", ".js", ".yaml", ".yml"}
129
+
130
  md_paths = [
131
  item["path"]
132
  for item in tree
133
  if item["type"] == "blob"
134
+ and any(item["path"].endswith(ext) for ext in INDEXABLE_EXTENSIONS)
135
+ and (not self._path_filter or item["path"].startswith(self._path_filter))
136
+ and item.get("size", 0) <= 200_000
137
  ]
138
 
139
  docs: list[RawDocument] = []
 
154
  source_url = data.get("html_url", f"{self._repo_url}/blob/{self._branch}/{path}")
155
  return RawDocument(
156
  doc_id=doc_id,
157
+ title=path.split("/")[-1].rsplit(".", 1)[0],
158
  content=content,
159
  source_url=source_url,
160
  source_type="github",
ingestion/storage/bm25_store.py CHANGED
@@ -24,15 +24,17 @@ class BM25Store:
24
  return []
25
  return re.findall(r"\b\w+\b", text.lower())
26
 
27
- def rebuild_index(self, chunk_ids: List[str], texts: List[str]) -> None:
28
  if not chunk_ids or not texts:
29
  logger.warning("BM25: empty corpus provided")
30
  return
31
  if len(chunk_ids) != len(texts):
32
  raise ValueError(f"chunk_ids ({len(chunk_ids)}) != texts ({len(texts)})")
 
 
33
 
34
- cleaned_ids, cleaned_texts, tokenized_corpus = [], [], []
35
- for chunk_id, text in zip(chunk_ids, texts):
36
  if not chunk_id or not text or not text.strip():
37
  continue
38
  tokens = self.tokenize(text)
@@ -41,12 +43,18 @@ class BM25Store:
41
  cleaned_ids.append(chunk_id)
42
  cleaned_texts.append(text)
43
  tokenized_corpus.append(tokens)
 
 
44
 
45
  if not tokenized_corpus:
46
  raise ValueError("No valid documents to index")
47
 
 
 
 
 
48
  with self.index_path.open("wb") as f:
49
- pickle.dump({"index": BM25Okapi(tokenized_corpus), "doc_ids": cleaned_ids, "corpus": cleaned_texts}, f)
50
 
51
  logger.info("BM25 rebuilt with %d documents -> %s", len(cleaned_ids), self.index_path)
52
 
@@ -78,4 +86,5 @@ def rebuild_from_supabase() -> None:
78
  store.rebuild_index(
79
  chunk_ids=[r["chunk_id"] for r in rows],
80
  texts=[r["text"] for r in rows],
 
81
  )
 
24
  return []
25
  return re.findall(r"\b\w+\b", text.lower())
26
 
27
+ def rebuild_index(self, chunk_ids: List[str], texts: List[str], metadata: List[dict] | None = None) -> None:
28
  if not chunk_ids or not texts:
29
  logger.warning("BM25: empty corpus provided")
30
  return
31
  if len(chunk_ids) != len(texts):
32
  raise ValueError(f"chunk_ids ({len(chunk_ids)}) != texts ({len(texts)})")
33
+ if metadata is not None and len(metadata) != len(chunk_ids):
34
+ raise ValueError(f"metadata ({len(metadata)}) != chunk_ids ({len(chunk_ids)})")
35
 
36
+ cleaned_ids, cleaned_texts, tokenized_corpus, cleaned_metadata = [], [], [], []
37
+ for i, (chunk_id, text) in enumerate(zip(chunk_ids, texts)):
38
  if not chunk_id or not text or not text.strip():
39
  continue
40
  tokens = self.tokenize(text)
 
43
  cleaned_ids.append(chunk_id)
44
  cleaned_texts.append(text)
45
  tokenized_corpus.append(tokens)
46
+ if metadata is not None:
47
+ cleaned_metadata.append(metadata[i])
48
 
49
  if not tokenized_corpus:
50
  raise ValueError("No valid documents to index")
51
 
52
+ payload = {"index": BM25Okapi(tokenized_corpus), "doc_ids": cleaned_ids, "corpus": cleaned_texts}
53
+ if metadata is not None:
54
+ payload["metadata"] = cleaned_metadata
55
+
56
  with self.index_path.open("wb") as f:
57
+ pickle.dump(payload, f)
58
 
59
  logger.info("BM25 rebuilt with %d documents -> %s", len(cleaned_ids), self.index_path)
60
 
 
86
  store.rebuild_index(
87
  chunk_ids=[r["chunk_id"] for r in rows],
88
  texts=[r["text"] for r in rows],
89
+ metadata=[{"source": r.get("source", ""), "source_type": r.get("source_type", "internal")} for r in rows],
90
  )
ingestion/storage/supabase_store.py CHANGED
@@ -134,7 +134,7 @@ def update_cag_snapshot(team_id: str, snapshot: str, client: Optional[Client] =
134
  def get_all_chunks(client: Optional[Client] = None) -> list[dict[str, Any]]:
135
  sb = client or get_client()
136
  try:
137
- result = sb.table("chunks").select("chunk_id, text").execute()
138
  return result.data or []
139
  except Exception:
140
  logger.exception("supabase_store: failed to fetch all chunks")
 
134
  def get_all_chunks(client: Optional[Client] = None) -> list[dict[str, Any]]:
135
  sb = client or get_client()
136
  try:
137
+ result = sb.table("chunks").select("chunk_id, text, source, source_type").execute()
138
  return result.data or []
139
  except Exception:
140
  logger.exception("supabase_store: failed to fetch all chunks")
main.py CHANGED
@@ -77,7 +77,7 @@ app.include_router(tools_router)
77
 
78
 
79
  # ---------------------------------------------------------------------------
80
- # Health endpoint — pinged by admin dashboard + Docker healthcheck
81
  # ---------------------------------------------------------------------------
82
 
83
  @app.get("/health", tags=["infra"])
@@ -130,3 +130,22 @@ async def health() -> dict:
130
  results["status"] = "degraded"
131
 
132
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
 
79
  # ---------------------------------------------------------------------------
80
+ # Health endpoint — must be registered BEFORE the SPA catch-all
81
  # ---------------------------------------------------------------------------
82
 
83
  @app.get("/health", tags=["infra"])
 
130
  results["status"] = "degraded"
131
 
132
  return results
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Serve React build — SPA catch-all MUST be last (catches everything else)
137
+ # ---------------------------------------------------------------------------
138
+ import os as _os
139
+ from fastapi.staticfiles import StaticFiles
140
+ from fastapi.responses import FileResponse as _FileResponse
141
+
142
+ _dist = _os.path.join(_os.path.dirname(__file__), "frontend", "dist")
143
+ if _os.path.exists(_dist):
144
+ app.mount("/assets", StaticFiles(directory=_os.path.join(_dist, "assets")), name="assets")
145
+
146
+ @app.get("/{full_path:path}", include_in_schema=False)
147
+ async def serve_spa(full_path: str):
148
+ file = _os.path.join(_dist, full_path)
149
+ if _os.path.isfile(file):
150
+ return _FileResponse(file)
151
+ return _FileResponse(_os.path.join(_dist, "index.html"))
migrate_qdrant.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Migrate local Qdrant collection to Qdrant Cloud. Run once before deploying."""
2
+ import asyncio, os, sys
3
+
4
+ COLLECTION = "knowledge_base"
5
+ CLOUD_URL = os.getenv("QDRANT_CLOUD_URL", "")
6
+ CLOUD_KEY = os.getenv("QDRANT_CLOUD_API_KEY", "")
7
+
8
+ if not CLOUD_URL or not CLOUD_KEY:
9
+ print("Set QDRANT_CLOUD_URL and QDRANT_CLOUD_API_KEY env vars first")
10
+ sys.exit(1)
11
+
12
+ async def migrate():
13
+ from qdrant_client import AsyncQdrantClient
14
+ from qdrant_client.models import PointStruct
15
+
16
+ src = AsyncQdrantClient(host="localhost", port=6333)
17
+ dst = AsyncQdrantClient(url=CLOUD_URL, api_key=CLOUD_KEY)
18
+
19
+ info = await src.get_collection(COLLECTION)
20
+ print(f"Source collection: {info.points_count} points")
21
+
22
+ try:
23
+ await dst.delete_collection(COLLECTION)
24
+ except Exception:
25
+ pass
26
+
27
+ # Create with both dense and sparse vector configs
28
+ await dst.create_collection(
29
+ COLLECTION,
30
+ vectors_config=info.config.params.vectors,
31
+ sparse_vectors_config=info.config.params.sparse_vectors,
32
+ )
33
+ print("Created cloud collection (dense + sparse)")
34
+
35
+ offset = None
36
+ total = 0
37
+ while True:
38
+ records, offset = await src.scroll(
39
+ COLLECTION, offset=offset, limit=50,
40
+ with_payload=True, with_vectors=True
41
+ )
42
+ if not records:
43
+ break
44
+ structs = [
45
+ PointStruct(id=r.id, vector=r.vector, payload=r.payload or {})
46
+ for r in records
47
+ if r.vector is not None
48
+ ]
49
+ if structs:
50
+ await dst.upsert(COLLECTION, points=structs)
51
+ total += len(structs)
52
+ print(f"Migrated {total} points...")
53
+ if offset is None:
54
+ break
55
+
56
+ print(f"Done — {total} points migrated to {CLOUD_URL}")
57
+
58
+ asyncio.run(migrate())
requirements.txt CHANGED
@@ -17,10 +17,13 @@
17
  langgraph==0.2.76
18
  langchain-core==0.3.86
19
  langchain-google-genai==2.0.9
 
20
 
21
 
22
  # ── Embeddings & reranking ────────────────────────────────────────────────────
23
- FlagEmbedding==1.3.0
 
 
24
 
25
 
26
  # ── PII masking (local — zero egress) ────────────────────────────────────────
@@ -68,9 +71,10 @@ google-auth==2.37.0
68
  numpy==1.26.4
69
  # torch: CPU build. For GPU replace with the matching CUDA wheel:
70
  # https://pytorch.org/get-started/locally/
71
- # torch 2.6 is compatible with numpy 1.26.x and transformers 4.51.x.
 
72
  torch==2.6.0
73
- transformers==4.51.0
74
 
75
 
76
  # ── Document parsing ─────────────────────────────────────────────────────────
@@ -135,7 +139,7 @@ watchdog==6.0.0
135
  # Ubuntu: apt install tesseract-ocr
136
  # Windows: https://github.com/UB-Mannheim/tesseract/wiki
137
  pytesseract==0.3.13
138
- Pillow==11.1.0
139
 
140
 
141
  # ── Optional: live web fetching (configure via .env) ─────────────────────────
@@ -143,5 +147,8 @@ Pillow==11.1.0
143
  # firecrawl-py==1.9.0
144
  # tavily-python==0.5.0
145
 
 
 
 
146
  # ── NL-to-SQL agent — direct PostgreSQL connection ────────────────────────────
147
  asyncpg>=0.29.0
 
17
  langgraph==0.2.76
18
  langchain-core==0.3.86
19
  langchain-google-genai==2.0.9
20
+ langchain-openai==1.2.1
21
 
22
 
23
  # ── Embeddings & reranking ────────────────────────────────────────────────────
24
+ FlagEmbedding==1.4.0
25
+ # CrossEncoder reranker — replaces BGE reranker (Python 3.14 compatible)
26
+ sentence-transformers==5.4.1
27
 
28
 
29
  # ── PII masking (local — zero egress) ────────────────────────────────────────
 
71
  numpy==1.26.4
72
  # torch: CPU build. For GPU replace with the matching CUDA wheel:
73
  # https://pytorch.org/get-started/locally/
74
+ # torch 2.6 is compatible with numpy 1.26.x and transformers 5.1.x.
75
+ # transformers is constrained to <5.2.0 by gliner 0.2.x.
76
  torch==2.6.0
77
+ transformers==5.1.0
78
 
79
 
80
  # ── Document parsing ─────────────────────────────────────────────────────────
 
139
  # Ubuntu: apt install tesseract-ocr
140
  # Windows: https://github.com/UB-Mannheim/tesseract/wiki
141
  pytesseract==0.3.13
142
+ Pillow==12.2.0
143
 
144
 
145
  # ── Optional: live web fetching (configure via .env) ─────────────────────────
 
147
  # firecrawl-py==1.9.0
148
  # tavily-python==0.5.0
149
 
150
+ # ── Auth — password hashing ───────────────────────────────────────────────────
151
+ bcrypt==5.0.0
152
+
153
  # ── NL-to-SQL agent — direct PostgreSQL connection ────────────────────────────
154
  asyncpg>=0.29.0
src/admin/router.py CHANGED
@@ -31,24 +31,26 @@ async def _redis() -> aioredis.Redis:
31
  def _default_sources() -> list[dict]:
32
  """Build source list from env vars that are set."""
33
  sources = []
34
- if os.getenv("JIRA_INSTANCE_URL") or settings.integrations.jira_instance_url:
 
35
  sources.append({
36
  "id": "jira-default",
37
  "type": "jira",
38
  "name": "Jira",
39
- "url": settings.integrations.jira_instance_url or "https://your-org.atlassian.net",
40
- "enabled": bool(settings.integrations.jira_api_token),
41
  "last_sync": None,
42
  "sync_status": "idle",
43
  "error_msg": None,
44
  })
45
- if os.getenv("CONFLUENCE_URL") or os.getenv("CONFLUENCE_SPACE_KEY"):
 
46
  sources.append({
47
  "id": "confluence-default",
48
  "type": "confluence",
49
  "name": "Confluence",
50
- "url": os.getenv("CONFLUENCE_URL", "https://your-org.atlassian.net/wiki"),
51
- "enabled": bool(os.getenv("CONFLUENCE_API_TOKEN")),
52
  "last_sync": None,
53
  "sync_status": "idle",
54
  "error_msg": None,
 
31
  def _default_sources() -> list[dict]:
32
  """Build source list from env vars that are set."""
33
  sources = []
34
+ jira_url = os.getenv("JIRA_BASE_URL") or os.getenv("JIRA_INSTANCE_URL") or settings.integrations.jira_instance_url
35
+ if jira_url:
36
  sources.append({
37
  "id": "jira-default",
38
  "type": "jira",
39
  "name": "Jira",
40
+ "url": jira_url,
41
+ "enabled": bool(os.getenv("JIRA_API_TOKEN") or settings.integrations.jira_api_token),
42
  "last_sync": None,
43
  "sync_status": "idle",
44
  "error_msg": None,
45
  })
46
+ confluence_url = os.getenv("CONFLUENCE_BASE_URL") or os.getenv("CONFLUENCE_URL")
47
+ if confluence_url:
48
  sources.append({
49
  "id": "confluence-default",
50
  "type": "confluence",
51
  "name": "Confluence",
52
+ "url": confluence_url,
53
+ "enabled": bool(os.getenv("CONFLUENCE_TOKEN") or os.getenv("CONFLUENCE_API_TOKEN")),
54
  "last_sync": None,
55
  "sync_status": "idle",
56
  "error_msg": None,
src/admin/users_api.py CHANGED
@@ -52,7 +52,7 @@ async def list_users(user=Depends(require_role("admin", "org_admin"))) -> dict:
52
  result = (
53
  _client()
54
  .table("users")
55
- .select("id, email, name, role, is_active, is_new_hire, workspace_id, team_id")
56
  .eq("workspace_id", DEFAULT_WORKSPACE_ID)
57
  .order("name")
58
  .execute()
 
52
  result = (
53
  _client()
54
  .table("users")
55
+ .select("id, email, name, role, is_active")
56
  .eq("workspace_id", DEFAULT_WORKSPACE_ID)
57
  .order("name")
58
  .execute()
src/auth/email.py CHANGED
@@ -13,23 +13,26 @@ logger = logging.getLogger(__name__)
13
 
14
  def send_email(to: str, subject: str, html: str) -> None:
15
  """Fire-and-forget — logs on failure, never raises."""
16
- if not settings.smtp_host or not settings.smtp_user:
17
- logger.warning("email_not_configured — skipping send to %s", to)
18
- return
19
 
20
- def _send():
21
- try:
22
- msg = MIMEMultipart("alternative")
23
- msg["Subject"] = subject
24
- msg["From"] = settings.smtp_from
25
- msg["To"] = to
26
- msg.attach(MIMEText(html, "html"))
27
- with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as s:
28
- s.starttls()
29
- s.login(settings.smtp_user, settings.smtp_password)
30
- s.sendmail(settings.smtp_from, to, msg.as_string())
31
- logger.info("email_sent to=%s subject=%s", to, subject)
32
- except Exception:
33
- logger.exception("email_send_failed to=%s", to)
34
 
35
- threading.Thread(target=_send, daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  def send_email(to: str, subject: str, html: str) -> None:
15
  """Fire-and-forget — logs on failure, never raises."""
16
+ threading.Thread(target=lambda: send_email_sync(to, subject, html), daemon=True).start()
 
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ def send_email_sync(to: str, subject: str, html: str) -> bool:
20
+ """Synchronous send — returns True if sent, False if skipped or failed."""
21
+ if not settings.smtp_host or not settings.smtp_user:
22
+ logger.warning("email_not_configured — skipping send to %s", to)
23
+ return False
24
+ try:
25
+ msg = MIMEMultipart("alternative")
26
+ msg["Subject"] = subject
27
+ msg["From"] = settings.smtp_from
28
+ msg["To"] = to
29
+ msg.attach(MIMEText(html, "html"))
30
+ with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as s:
31
+ s.starttls()
32
+ s.login(settings.smtp_user, settings.smtp_password)
33
+ s.sendmail(settings.smtp_from, to, msg.as_string())
34
+ logger.info("email_sent to=%s subject=%s", to, subject)
35
+ return True
36
+ except Exception:
37
+ logger.exception("email_send_failed to=%s", to)
38
+ return False
src/auth/router.py CHANGED
@@ -432,10 +432,11 @@ async def send_invite(
432
  f"<p><a href=\"{invite_url}\">Accept your invitation</a></p>"
433
  f"<p>This link expires in 7 days.</p>"
434
  )
435
- send_email(to=email, subject="You're invited to GodSpeed", html=html)
 
436
 
437
- logger.info("invite_sent to=%s by=%s role=%s", email, caller["id"], body.role)
438
- return {"ok": True, "email": email}
439
 
440
 
441
  @router.get("/invite/{token}")
 
432
  f"<p><a href=\"{invite_url}\">Accept your invitation</a></p>"
433
  f"<p>This link expires in 7 days.</p>"
434
  )
435
+ from src.auth.email import send_email_sync
436
+ email_sent = send_email_sync(to=email, subject="You're invited to GodSpeed", html=html)
437
 
438
+ logger.info("invite_sent to=%s by=%s role=%s email_sent=%s", email, caller["id"], body.role, email_sent)
439
+ return {"ok": True, "email": email, "invite_url": invite_url, "email_sent": email_sent}
440
 
441
 
442
  @router.get("/invite/{token}")
src/confluence_agent/tasks.py CHANGED
@@ -15,7 +15,7 @@ def confluence_process_page(self, page_id: str, space_key: str = "", team_id: st
15
  """Webhook-triggered single-page ingestion."""
16
  try:
17
  from src.confluence_agent.pipeline import ingest_page
18
- count = asyncio.get_event_loop().run_until_complete(
19
  ingest_page(page_id, space_key, team_id or confluence_config.team_id)
20
  )
21
  return {"page_id": page_id, "chunks_stored": count}
@@ -29,7 +29,7 @@ def confluence_sync_space(self, space_key: str, team_id: str = "") -> dict:
29
  """Full space sync."""
30
  try:
31
  from src.confluence_agent.pipeline import ingest_space
32
- count = asyncio.get_event_loop().run_until_complete(
33
  ingest_space(space_key, team_id or confluence_config.team_id)
34
  )
35
  return {"space_key": space_key, "chunks_stored": count}
@@ -65,6 +65,6 @@ def confluence_periodic_sync() -> dict:
65
  results[space_key] = len(docs)
66
  return total
67
 
68
- total = asyncio.get_event_loop().run_until_complete(_sync_all())
69
  logger.info("confluence_periodic_sync: synced %d pages, %d total chunks", sum(results.values()), total)
70
  return {"spaces": results, "total_chunks": total}
 
15
  """Webhook-triggered single-page ingestion."""
16
  try:
17
  from src.confluence_agent.pipeline import ingest_page
18
+ count = asyncio.run(
19
  ingest_page(page_id, space_key, team_id or confluence_config.team_id)
20
  )
21
  return {"page_id": page_id, "chunks_stored": count}
 
29
  """Full space sync."""
30
  try:
31
  from src.confluence_agent.pipeline import ingest_space
32
+ count = asyncio.run(
33
  ingest_space(space_key, team_id or confluence_config.team_id)
34
  )
35
  return {"space_key": space_key, "chunks_stored": count}
 
65
  results[space_key] = len(docs)
66
  return total
67
 
68
+ total = asyncio.run(_sync_all())
69
  logger.info("confluence_periodic_sync: synced %d pages, %d total chunks", sum(results.values()), total)
70
  return {"spaces": results, "total_chunks": total}
src/jira_agent/tasks.py CHANGED
@@ -14,7 +14,7 @@ def jira_process_issue(self, issue_key: str, team_id: str = "") -> dict:
14
  """Webhook-triggered single-issue ingestion."""
15
  try:
16
  from src.jira_agent.pipeline import ingest_issue
17
- count = asyncio.get_event_loop().run_until_complete(
18
  ingest_issue(issue_key, team_id or jira_config.team_id)
19
  )
20
  return {"issue_key": issue_key, "chunks_stored": count}
@@ -28,7 +28,7 @@ def jira_sync_project(self, project_key: str, team_id: str = "") -> dict:
28
  """Full project sync — intended for manual trigger or scheduled use."""
29
  try:
30
  from src.jira_agent.pipeline import ingest_project
31
- count = asyncio.get_event_loop().run_until_complete(
32
  ingest_project(project_key, team_id or jira_config.team_id)
33
  )
34
  return {"project_key": project_key, "chunks_stored": count}
 
14
  """Webhook-triggered single-issue ingestion."""
15
  try:
16
  from src.jira_agent.pipeline import ingest_issue
17
+ count = asyncio.run(
18
  ingest_issue(issue_key, team_id or jira_config.team_id)
19
  )
20
  return {"issue_key": issue_key, "chunks_stored": count}
 
28
  """Full project sync — intended for manual trigger or scheduled use."""
29
  try:
30
  from src.jira_agent.pipeline import ingest_project
31
+ count = asyncio.run(
32
  ingest_project(project_key, team_id or jira_config.team_id)
33
  )
34
  return {"project_key": project_key, "chunks_stored": count}
start-ngrok.sh ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Start GodSpeed backend and ngrok tunnel
3
+
4
+ # Kill any existing ngrok / uvicorn
5
+ pkill -f "ngrok http" 2>/dev/null
6
+ pkill -f "uvicorn" 2>/dev/null
7
+ sleep 1
8
+
9
+ echo "Starting ngrok..."
10
+ ngrok http 8000 --log=stdout > /tmp/ngrok.log 2>&1 &
11
+ NGROK_PID=$!
12
+
13
+ # Wait for ngrok to be ready
14
+ for i in $(seq 1 15); do
15
+ NGROK_URL=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null | python3 -c "
16
+ import sys, json
17
+ try:
18
+ data = json.load(sys.stdin)
19
+ for t in data.get('tunnels', []):
20
+ if t.get('proto') == 'https':
21
+ print(t['public_url'])
22
+ break
23
+ except: pass
24
+ " 2>/dev/null)
25
+ [ -n "$NGROK_URL" ] && break
26
+ sleep 1
27
+ done
28
+
29
+ if [ -z "$NGROK_URL" ]; then
30
+ echo "ERROR: Could not get ngrok URL. Is ngrok authenticated? Run: ngrok config add-authtoken <token>"
31
+ kill $NGROK_PID 2>/dev/null
32
+ exit 1
33
+ fi
34
+
35
+ echo "Starting backend with FRONTEND_URL=$NGROK_URL ..."
36
+ FRONTEND_URL=$NGROK_URL .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 &
37
+ BACKEND_PID=$!
38
+
39
+ sleep 2
40
+
41
+ echo ""
42
+ echo "========================================="
43
+ echo " GodSpeed is live at: $NGROK_URL"
44
+ echo " Share this URL with your friends!"
45
+ echo "========================================="
46
+ echo ""
47
+ echo "Press Ctrl+C to stop everything"
48
+
49
+ trap "kill $BACKEND_PID $NGROK_PID 2>/dev/null; exit" INT
50
+ wait