galbendavids commited on
Commit
b4d57a6
ยท
1 Parent(s): 7006210
.query_history.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื",
4
+ "response": {
5
+ "summary": "ื”ืงืœื•ืช ืฉื‘ื” ื ื™ืชืŸ ืœื”ืชืœื•ื ืŸ ืฉืืคืฉืจ ื‘ืงืœื•ืช ืœื”ื’ื™ืฉ ืชืœื•ื ื” ืจืฆื•ื™ ืœืืคืฉืจ ื”ื’ืฉื” ืฉืœ ื›ืžื” ืชืœื•ื ื•ืช ื™ื—ื“, ืžืžืกืคืจื™ื ืฉื•ื ื™ื ื›ื“ื™ ืœื—ืกื•ืš ื‘ื™ืจื•ืงืจื˜ื™ื”.\nืชื•ื“ื”\nื—ื ื” ืฉื“ื•ืช ื‘ื—ื™ืจืช ื”ืชืœื•ื ื” ืžื™ื ื™ืžืœื™ื•ืช ืื– ืื™ืคื” ืืคืฉืจ ืœื›ืชื•ื‘ ืชื•ื“ื•ืช ืคืจื˜ ืœืชืœื•ื ื•ืช?"
6
+ }
7
+ },
8
+ {
9
+ "query": "ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื",
10
+ "response": {
11
+ "summary": "ื”ืงืœื•ืช ืฉื‘ื” ื ื™ืชืŸ ืœื”ืชืœื•ื ืŸ ืฉืืคืฉืจ ื‘ืงืœื•ืช ืœื”ื’ื™ืฉ ืชืœื•ื ื” ืจืฆื•ื™ ืœืืคืฉืจ ื”ื’ืฉื” ืฉืœ ื›ืžื” ืชืœื•ื ื•ืช ื™ื—ื“, ืžืžืกืคืจื™ื ืฉื•ื ื™ื ื›ื“ื™ ืœื—ืกื•ืš ื‘ื™ืจื•ืงืจื˜ื™ื”.\nืชื•ื“ื”\nื—ื ื” ืฉื“ื•ืช ื‘ื—ื™ืจืช ื”ืชืœื•ื ื” ืžื™ื ื™ืžืœื™ื•ืช ืื– ืื™ืคื” ืืคืฉืจ ืœื›ืชื•ื‘ ืชื•ื“ื•ืช ืคืจื˜ ืœืชืœื•ื ื•ืช?"
12
+ }
13
+ },
14
+ {
15
+ "query": "ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื",
16
+ "response": {
17
+ "summary": "ื”ืงืœื•ืช ืฉื‘ื” ื ื™ืชืŸ ืœื”ืชืœื•ื ืŸ ืฉืืคืฉืจ ื‘ืงืœื•ืช ืœื”ื’ื™ืฉ ืชืœื•ื ื” ืจืฆื•ื™ ืœืืคืฉืจ ื”ื’ืฉื” ืฉืœ ื›ืžื” ืชืœื•ื ื•ืช ื™ื—ื“, ืžืžืกืคืจื™ื ืฉื•ื ื™ื ื›ื“ื™ ืœื—ืกื•ืš ื‘ื™ืจื•ืงืจื˜ื™ื”.\nืชื•ื“ื”\nื—ื ื” ืฉื“ื•ืช ื‘ื—ื™ืจืช ื”ืชืœื•ื ื” ืžื™ื ื™ืžืœื™ื•ืช ืื– ืื™ืคื” ืืคืฉืจ ืœื›ืชื•ื‘ ืชื•ื“ื•ืช ืคืจื˜ ืœืชืœื•ื ื•ืช?"
18
+ }
19
+ },
20
+ {
21
+ "query": "ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื",
22
+ "response": {
23
+ "summary": "ื”ืงืœื•ืช ืฉื‘ื” ื ื™ืชืŸ ืœื”ืชืœื•ื ืŸ ืฉืืคืฉืจ ื‘ืงืœื•ืช ืœื”ื’ื™ืฉ ืชืœื•ื ื” ืจืฆื•ื™ ืœืืคืฉืจ ื”ื’ืฉื” ืฉืœ ื›ืžื” ืชืœื•ื ื•ืช ื™ื—ื“, ืžืžืกืคืจื™ื ืฉื•ื ื™ื ื›ื“ื™ ืœื—ืกื•ืš ื‘ื™ืจื•ืงืจื˜ื™ื”.\nืชื•ื“ื”\nื—ื ื” ืฉื“ื•ืช ื‘ื—ื™ืจืช ื”ืชืœื•ื ื” ืžื™ื ื™ืžืœื™ื•ืช ืื– ืื™ืคื” ืืคืฉืจ ืœื›ืชื•ื‘ ืชื•ื“ื•ืช ืคืจื˜ ืœืชืœื•ื ื•ืช?"
24
+ }
25
+ }
26
+ ]
.uvicorn.log ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ INFO: Started server process [70640]
2
+ INFO: Waiting for application startup.
3
+ INFO: Application startup complete.
4
+ INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
5
+
6
+
7
+ huggingface/tokenizers: The current process just got forked, after parallelism has already been used. Disabling parallelism to avoid deadlocks...
8
+ To disable this warning, you can either:
9
+ - Avoid using `tokenizers` before the fork if possible
10
+ - Explicitly set the environment variable TOKENIZERS_PARALLELISM=(true | false)
11
+ INFO: 127.0.0.1:55678 - "POST /query HTTP/1.1" 200 OK
12
+
13
+
14
+ INFO: 127.0.0.1:55706 - "POST /query HTTP/1.1" 200 OK
15
+ INFO: 127.0.0.1:55717 - "GET / HTTP/1.1" 200 OK
16
+ INFO: 127.0.0.1:55717 - "GET /static/app.js HTTP/1.1" 200 OK
17
+ INFO: 127.0.0.1:55717 - "POST /health HTTP/1.1" 200 OK
18
+ INFO: 127.0.0.1:55717 - "GET /history HTTP/1.1" 200 OK
19
+
20
+ INFO: 127.0.0.1:55717 - "POST /query HTTP/1.1" 200 OK
21
+ INFO: 127.0.0.1:55717 - "GET /history HTTP/1.1" 200 OK
22
+
23
+ INFO: 127.0.0.1:55726 - "POST /query HTTP/1.1" 200 OK
24
+ INFO: 127.0.0.1:55737 - "GET / HTTP/1.1" 200 OK
25
+ INFO: 127.0.0.1:55737 - "GET /static/app.js HTTP/1.1" 304 Not Modified
26
+ INFO: 127.0.0.1:55737 - "POST /health HTTP/1.1" 200 OK
27
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
28
+ INFO: 127.0.0.1:55737 - "GET / HTTP/1.1" 200 OK
29
+ INFO: 127.0.0.1:55737 - "POST /health HTTP/1.1" 200 OK
30
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
31
+
32
+ INFO: 127.0.0.1:55737 - "POST /query HTTP/1.1" 200 OK
33
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
34
+ INFO: 127.0.0.1:55737 - "GET / HTTP/1.1" 200 OK
35
+ INFO: 127.0.0.1:55737 - "POST /health HTTP/1.1" 200 OK
36
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
37
+ INFO: 127.0.0.1:55737 - "POST /history/clear HTTP/1.1" 200 OK
38
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
39
+ INFO: 127.0.0.1:55737 - "POST /history/clear HTTP/1.1" 200 OK
40
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
41
+ INFO: 127.0.0.1:55737 - "POST /history/clear HTTP/1.1" 200 OK
42
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
43
+ INFO: 127.0.0.1:55737 - "POST /history/clear HTTP/1.1" 200 OK
44
+ INFO: 127.0.0.1:55737 - "GET /history HTTP/1.1" 200 OK
45
+ INFO: 127.0.0.1:55752 - "GET /apple-touch-icon-precomposed.png HTTP/1.1" 404 Not Found
46
+ INFO: 127.0.0.1:55753 - "GET /apple-touch-icon.png HTTP/1.1" 404 Not Found
47
+ INFO: 127.0.0.1:55754 - "GET /favicon.ico HTTP/1.1" 404 Not Found
48
+ INFO: 127.0.0.1:55795 - "GET / HTTP/1.1" 200 OK
49
+ INFO: 127.0.0.1:55795 - "GET /static/app.js HTTP/1.1" 200 OK
50
+ INFO: 127.0.0.1:55795 - "POST /health HTTP/1.1" 200 OK
51
+ INFO: 127.0.0.1:55795 - "GET /history HTTP/1.1" 200 OK
52
+ INFO: 127.0.0.1:55795 - "GET /favicon.ico HTTP/1.1" 404 Not Found
53
+
54
+ INFO: 127.0.0.1:55795 - "POST /query HTTP/1.1" 200 OK
55
+ INFO: 127.0.0.1:55795 - "GET /history HTTP/1.1" 200 OK
56
+
57
+ INFO: 127.0.0.1:55821 - "POST /query HTTP/1.1" 200 OK
58
+ INFO: 127.0.0.1:55821 - "GET /history HTTP/1.1" 200 OK
59
+
60
+ INFO: 127.0.0.1:55824 - "POST /query HTTP/1.1" 200 OK
61
+ INFO: 127.0.0.1:55824 - "GET /history HTTP/1.1" 200 OK
62
+
63
+ INFO: 127.0.0.1:55824 - "POST /query HTTP/1.1" 200 OK
64
+ INFO: 127.0.0.1:55824 - "GET /history HTTP/1.1" 200 OK
65
+
66
+ INFO: 127.0.0.1:55824 - "POST /query HTTP/1.1" 200 OK
67
+ INFO: 127.0.0.1:55824 - "GET /history HTTP/1.1" 200 OK
68
+
69
+ INFO: 127.0.0.1:55839 - "POST /query HTTP/1.1" 200 OK
70
+ INFO: 127.0.0.1:55839 - "GET /history HTTP/1.1" 200 OK
71
+ INFO: 127.0.0.1:55846 - "POST /history/clear HTTP/1.1" 200 OK
72
+ INFO: 127.0.0.1:55846 - "GET /history HTTP/1.1" 200 OK
73
+ INFO: 127.0.0.1:55846 - "POST /history/clear HTTP/1.1" 200 OK
74
+ INFO: 127.0.0.1:55846 - "GET /history HTTP/1.1" 200 OK
75
+ INFO: 127.0.0.1:55846 - "POST /history/clear HTTP/1.1" 200 OK
76
+ INFO: 127.0.0.1:55846 - "GET /history HTTP/1.1" 200 OK
77
+
78
+
79
+
80
+ INFO: 127.0.0.1:55846 - "POST /query HTTP/1.1" 200 OK
81
+ INFO: 127.0.0.1:55846 - "GET /history HTTP/1.1" 200 OK
82
+ INFO: 127.0.0.1:55850 - "POST /query HTTP/1.1" 200 OK
83
+ INFO: 127.0.0.1:55850 - "GET /history HTTP/1.1" 200 OK
84
+
85
+ INFO: 127.0.0.1:55849 - "POST /query HTTP/1.1" 200 OK
86
+ INFO: 127.0.0.1:55849 - "GET /history HTTP/1.1" 200 OK
87
+ INFO: 127.0.0.1:55850 - "POST /query HTTP/1.1" 200 OK
88
+ INFO: 127.0.0.1:55850 - "GET /history HTTP/1.1" 200 OK
PROJECT_COMPLETE.md CHANGED
@@ -30,6 +30,7 @@ Build a **Feedback Analysis RAG Agent** that:
30
  - [x] Topic extraction (k-means clustering)
31
  - [x] Sentiment analysis (multilingual)
32
  - [x] Error handling and validation
 
33
 
34
  ### Infrastructure (Complete)
35
  - [x] Virtual environment setup (.venv)
 
30
  - [x] Topic extraction (k-means clustering)
31
  - [x] Sentiment analysis (multilingual)
32
  - [x] Error handling and validation
33
+ - [x] Free-form RAG synthesizer (analyst-style, broader-context responses)
34
 
35
  ### Infrastructure (Complete)
36
  - [x] Virtual environment setup (.venv)
app/api.py CHANGED
@@ -4,9 +4,12 @@ from typing import List, Optional, Dict, Any
4
 
5
  import numpy as np
6
  import pandas as pd
7
- from fastapi import FastAPI, Query
8
- from fastapi.responses import ORJSONResponse
9
- from pydantic import BaseModel
 
 
 
10
 
11
  from .config import settings
12
  from .data_loader import load_feedback
@@ -21,16 +24,36 @@ app = FastAPI(title="Feedback Analysis RAG Agent", version="1.0.0", default_resp
21
  svc = RAGService()
22
  embedder = svc.embedder
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  class QueryRequest(BaseModel):
26
- query: str
27
- top_k: int = 5
28
 
29
 
30
  class QueryResponse(BaseModel):
31
  query: str
32
  summary: Optional[str]
33
- results: List[Dict[str, Any]]
 
34
 
35
 
36
  @app.post("/health")
@@ -55,40 +78,54 @@ def ingest() -> Dict[str, Any]:
55
 
56
 
57
  @app.post("/query", response_model=QueryResponse)
58
- def query(req: QueryRequest) -> QueryResponse:
59
- """Free-form question answering over feedback data."""
 
 
 
 
60
  try:
61
  # Use the higher-level answer pipeline which can handle counts and keyword queries
62
  out = svc.answer(req.query, top_k=req.top_k)
63
- return QueryResponse(
64
- query=out.query,
65
- summary=out.summary,
66
- results=[
67
- {
68
- "score": float(r.score), # Convert numpy float to Python float
69
- "service": str(r.row.get(settings.service_column, "")),
70
- "level": str(r.row.get(settings.level_column, "")),
71
- "text": str(r.row.get(settings.text_column, "")),
72
- }
73
- for r in out.results
74
- ],
75
- )
76
  except FileNotFoundError:
77
- return QueryResponse(
78
  query=req.query,
79
  summary="Error: Vector index not found. Please run /ingest first.",
80
- results=[]
81
  )
 
 
 
 
 
 
82
  except Exception as e:
83
- return QueryResponse(
84
  query=req.query,
85
  summary=f"Error: {str(e)}",
86
- results=[]
87
  )
 
 
 
 
 
 
88
 
89
 
90
  class TopicsRequest(BaseModel):
91
- num_topics: int = 5
92
 
93
 
94
  @app.post("/topics")
@@ -183,7 +220,7 @@ def topics(req: TopicsRequest) -> Dict[str, Any]:
183
 
184
 
185
  class SentimentRequest(BaseModel):
186
- limit: int = 100
187
 
188
 
189
  @app.post("/sentiment")
@@ -198,3 +235,33 @@ def sentiment(req: SentimentRequest) -> Dict[str, Any]:
198
  out = analyze_sentiments(texts)
199
  return {"count": len(out), "results": out}
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import numpy as np
6
  import pandas as pd
7
+ from fastapi import FastAPI, Query, Request
8
+ from fastapi.responses import ORJSONResponse, HTMLResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ import json
11
+ from pathlib import Path
12
+ from pydantic import BaseModel, Field
13
 
14
  from .config import settings
15
  from .data_loader import load_feedback
 
24
  svc = RAGService()
25
  embedder = svc.embedder
26
 
27
+ # Simple in-memory history persisted best-effort to `.query_history.json`
28
+ history_file = Path(".query_history.json")
29
+ history = []
30
+ if history_file.exists():
31
+ try:
32
+ with history_file.open("r", encoding="utf-8") as f:
33
+ history = json.load(f)
34
+ except Exception:
35
+ history = []
36
+
37
+
38
+ def save_history() -> None:
39
+ try:
40
+ with history_file.open("w", encoding="utf-8") as f:
41
+ json.dump(history, f, ensure_ascii=False, indent=2)
42
+ except Exception:
43
+ # best-effort persistence; ignore errors
44
+ pass
45
+
46
 
47
  class QueryRequest(BaseModel):
48
+ query: str = Field(..., example="ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื")
49
+ top_k: int = Field(5, example=5)
50
 
51
 
52
  class QueryResponse(BaseModel):
53
  query: str
54
  summary: Optional[str]
55
+ # `results` (example records) removed deliberately: API returns only
56
+ # an analyst-style `summary` to clients.
57
 
58
 
59
  @app.post("/health")
 
78
 
79
 
80
  @app.post("/query", response_model=QueryResponse)
81
+ def query(req: QueryRequest, request: Request) -> QueryResponse:
82
+ """Free-form question answering over feedback data.
83
+
84
+ This endpoint also appends the (request, response) pair to an in-memory history
85
+ which is persisted best-effort to `.query_history.json`.
86
+ """
87
  try:
88
  # Use the higher-level answer pipeline which can handle counts and keyword queries
89
  out = svc.answer(req.query, top_k=req.top_k)
90
+
91
+ # Only return the analyst-style summary to the client. Do not include
92
+ # example records or raw contexts in the API response.
93
+ resp_dict = {"query": out.query, "summary": out.summary}
94
+
95
+ # append to history (store only the summary)
96
+ try:
97
+ history.append({"query": out.query, "response": {"summary": out.summary}})
98
+ save_history()
99
+ except Exception:
100
+ pass
101
+
102
+ return QueryResponse(**resp_dict)
103
  except FileNotFoundError:
104
+ resp = QueryResponse(
105
  query=req.query,
106
  summary="Error: Vector index not found. Please run /ingest first.",
 
107
  )
108
+ try:
109
+ history.append({"query": resp.query, "response": {"summary": resp.summary}})
110
+ save_history()
111
+ except Exception:
112
+ pass
113
+ return resp
114
  except Exception as e:
115
+ resp = QueryResponse(
116
  query=req.query,
117
  summary=f"Error: {str(e)}",
 
118
  )
119
+ try:
120
+ history.append({"query": resp.query, "response": {"summary": resp.summary}})
121
+ save_history()
122
+ except Exception:
123
+ pass
124
+ return resp
125
 
126
 
127
  class TopicsRequest(BaseModel):
128
+ num_topics: int = Field(5, example=5)
129
 
130
 
131
  @app.post("/topics")
 
220
 
221
 
222
  class SentimentRequest(BaseModel):
223
+ limit: int = Field(100, example=50)
224
 
225
 
226
  @app.post("/sentiment")
 
235
  out = analyze_sentiments(texts)
236
  return {"count": len(out), "results": out}
237
 
238
+
239
+ # Mount static files for a simple frontend if present
240
+ static_dir = Path(__file__).parent / "static"
241
+ if static_dir.exists():
242
+ # Serve static assets under /static/* (so index.html can reference /static/app.js)
243
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
244
+
245
+
246
+ @app.get("/")
247
+ def root() -> HTMLResponse:
248
+ """Serve the main index.html for the frontend."""
249
+ try:
250
+ html = (static_dir / "index.html").read_text(encoding="utf-8")
251
+ return HTMLResponse(html)
252
+ except Exception:
253
+ return HTMLResponse("<html><body><h1>Frontend not available</h1></body></html>", status_code=404)
254
+
255
+
256
+ @app.get("/history")
257
+ def get_history() -> Dict[str, Any]:
258
+ return {"history": history}
259
+
260
+
261
+ @app.post("/history/clear")
262
+ def clear_history() -> Dict[str, Any]:
263
+ global history
264
+ history = []
265
+ save_history()
266
+ return {"status": "cleared"}
267
+
app/rag_service.py CHANGED
@@ -106,10 +106,175 @@ class RAGService:
106
  # Fallback: simple extractive "summary"
107
  return " ".join(contexts[:3])
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def query(self, query: str, top_k: int = 5) -> RetrievalOutput:
110
- results = self.retrieve(query, top_k=top_k)
 
 
 
 
111
  contexts = [r.row[settings.text_column] for r in results]
112
- summary = self.summarize(query, contexts)
 
 
113
  return RetrievalOutput(query=query, results=results, summary=summary)
114
 
115
  def answer(self, query: str, top_k: int = 5) -> RetrievalOutput:
 
106
  # Fallback: simple extractive "summary"
107
  return " ".join(contexts[:3])
108
 
109
+ def synthesize(self, query: str, results: List[SearchResult], contexts: List[str], max_contexts: int = 40) -> Optional[str]:
110
+ """Produce a free-form, analyst-style answer that synthesizes the retrieved contexts.
111
+
112
+ This method asks the LLM to act as an experienced data analyst for digital business
113
+ processes and to synthesize insights, root causes, business impact and recommended
114
+ next steps. It is explicitly not an extractive response of "most relevant" snippets.
115
+ """
116
+ if not contexts:
117
+ return None
118
+
119
+ # Limit number of contexts and truncate each to keep prompt size reasonable
120
+ safe_ctxs = []
121
+ for c in contexts[:max_contexts]:
122
+ # truncate to ~800 chars to avoid extremely long prompts
123
+ safe_ctxs.append((c[:800] + "...") if len(c) > 800 else c)
124
+
125
+ joined = "\n\n".join(f"- {c}" for c in safe_ctxs)
126
+
127
+ # Detect if query is in Hebrew
128
+ is_hebrew = any('\u0590' <= char <= '\u05FF' for char in query)
129
+ lang_instruction = "ืขื ื” ื‘ืขื‘ืจื™ืช ื‘ืื•ืคืŸ ืžืงืฆื•ืขื™" if is_hebrew else "Answer in the language of the query in a professional tone"
130
+
131
+ instruction = (
132
+ "You are an experienced information-systems analyst and senior data analyst working for a government ministry.\n"
133
+ "You have access to the full feedback dataset and metadata (service name, level).\n"
134
+ "Your task: produce a clear, structured, analyst-style answer to the user's question.\n"
135
+ "Requirements:\n"
136
+ "- Always synthesize across the provided examples and aggregate data; do NOT return raw snippets or 'most relevant' lists.\n"
137
+ "- Where applicable, compute simple aggregates over the dataset (counts, averages by service or by level).\n"
138
+ "- Provide: 1) Executive summary (2-4 sentences), 2) Key themes with short examples, 3) Likely root causes, 4) Business impact and priority, 5) Recommended next steps, 6) Suggested KPIs.\n"
139
+ "- If the user asks to split feedback into N topics, provide the N topics and a brief description and count for each.\n"
140
+ "- If the user asks about items with level < 3, filter by 'level' field accordingly and summarize services with low levels.\n"
141
+ "- Be explicit about uncertainty and flag where more data is needed.\n"
142
+ )
143
+
144
+ # Compute simple aggregates locally to include in the prompt: counts by service and average level
145
+ try:
146
+ df = load_feedback()
147
+ # basic aggregates
148
+ total = len(df)
149
+ counts_by_service = df.groupby(settings.service_column).size().sort_values(ascending=False).head(10).to_dict()
150
+ avg_level_by_service = df.groupby(settings.service_column)[settings.level_column].mean().sort_values(ascending=False).head(10).to_dict()
151
+ low_level_df = df[df[settings.level_column] < 3]
152
+ low_level_counts = low_level_df.groupby(settings.service_column).size().sort_values(ascending=False).head(10).to_dict()
153
+ except Exception:
154
+ total = None
155
+ counts_by_service = {}
156
+ avg_level_by_service = {}
157
+ low_level_counts = {}
158
+
159
+ aggregates_str = f"Total feedback: {total}\nTop services by count: {counts_by_service}\nTop services by avg level: {avg_level_by_service}\nLow-level counts (level<3): {low_level_counts}\n"
160
+
161
+ # Special-case: the user asked to split into N topics (e.g., "ื—ืœืง ืืช ื”ืžืฉื•ื‘ื™ื ืœ5 ื ื•ืฉืื™ื")
162
+ import re
163
+ m = re.search(r"(\d+)\s*ื ื•ืฉ", query)
164
+ if ("ื—ืœืง" in query and "ื ื•ืฉ" in query) or m:
165
+ try:
166
+ n_topics = int(m.group(1)) if m else 5
167
+ texts = df[settings.text_column].astype(str).tolist()
168
+ embeddings = self.embedder.encode(texts)
169
+ from .topics import kmeans_topics
170
+ res = kmeans_topics(embeddings, num_topics=n_topics)
171
+
172
+ # Build a compact summary of clusters with sample examples
173
+ clusters: Dict[int, list] = {}
174
+ for label, text in zip(res.labels, texts):
175
+ clusters.setdefault(int(label), []).append(text)
176
+
177
+ cluster_summaries = []
178
+ for tid, items in clusters.items():
179
+ sample = items[:3]
180
+ cluster_summaries.append(f"Cluster {tid}: count={len(items)}, examples: {sample}")
181
+
182
+ clusters_str = "\n".join(cluster_summaries[:n_topics])
183
+
184
+ prompt = (
185
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nCluster summaries (samples):\n{clusters_str}\n\n"
186
+ "Using the cluster summaries above, provide for each cluster: 1) a concise topic name (2-4 words), 2) 1-2 sentence description of the theme, and 3) recommended next steps/prioritization. Present as a numbered list."
187
+ )
188
+ except Exception:
189
+ # fallback to standard prompt if clustering fails
190
+ prompt = (
191
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nFeedback examples (truncated):\n{joined}\n\nPlease present a clear, actionable, and human-readable analysis."
192
+ )
193
+ # Send to LLM below
194
+ elif ("ื ืžื•ืš" in query and ("3" in query or "ืฉืœื•ืฉ" in query)) or ("level < 3" in query) or ("ืฆื™ื•ืŸ" in query and "3" in query and ("ื ืžื•ืš" in query or "ืžืชื—ืช" in query)):
195
+ # User asks about items with level < 3
196
+ try:
197
+ low_texts = low_level_df[settings.text_column].astype(str).tolist()
198
+ if low_texts:
199
+ embeddings = self.embedder.encode(low_texts)
200
+ from .topics import kmeans_topics
201
+ res = kmeans_topics(embeddings, num_topics=3)
202
+ clusters: Dict[int, list] = {}
203
+ for label, text in zip(res.labels, low_texts):
204
+ clusters.setdefault(int(label), []).append(text)
205
+ clusters_str = "\n".join([f"Cluster {tid}: count={len(items)}, examples: {items[:3]}" for tid, items in clusters.items()])
206
+ else:
207
+ clusters_str = "(no low-level feedback found)"
208
+
209
+ prompt = (
210
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates (low-level):\n{aggregates_str}\n\nLow-level cluster samples:\n{clusters_str}\n\n"
211
+ "Summarize the dominant topic(s) among low-rated feedback and identify which services are most affected. Provide recommended remediation steps and priority."
212
+ )
213
+ except Exception:
214
+ prompt = (
215
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nFeedback examples (truncated):\n{joined}\n\nPlease present a clear, actionable, and human-readable analysis."
216
+ )
217
+ elif "ืฉื™ืจื•ืชื™ื" in query or "ืฉื™ืจื•ืช" in query:
218
+ # User asked about services with issues vs services working well
219
+ try:
220
+ svc_stats = df.groupby(settings.service_column)[settings.level_column].agg(['mean','count']).sort_values('mean')
221
+ problematic = svc_stats[svc_stats['mean'] < 3].head(10).to_dict('index')
222
+ good = svc_stats[svc_stats['mean'] >= 4].head(10).to_dict('index')
223
+ svc_str = f"Problematic (mean<3): {problematic}\nWorking well (mean>=4): {good}\n"
224
+ prompt = (
225
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nService-level stats:\n{svc_str}\n\n"
226
+ "Using the service-level statistics and sampled feedback, describe which services have serious issues, which are working well overall, and provide prioritized recommendations for remediation and monitoring."
227
+ )
228
+ except Exception:
229
+ prompt = (
230
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nFeedback examples (truncated):\n{joined}\n\nPlease present a clear, actionable, and human-readable analysis."
231
+ )
232
+ else:
233
+ prompt = (
234
+ f"{instruction}\n\n{lang_instruction}.\n\nUser query:\n{query}\n\nDataset aggregates:\n{aggregates_str}\n\nFeedback examples (truncated):\n{joined}\n\nPlease present a clear, actionable, and human-readable analysis."
235
+ )
236
+
237
+ # Try Gemini first
238
+ if settings.gemini_api_key and genai is not None:
239
+ try:
240
+ genai.configure(api_key=settings.gemini_api_key)
241
+ model = genai.GenerativeModel("gemini-1.5-flash")
242
+ resp = model.generate_content(prompt)
243
+ text = getattr(resp, "text", None)
244
+ if isinstance(text, str) and text.strip():
245
+ return text.strip()
246
+ except Exception:
247
+ pass
248
+
249
+ # Fallback to OpenAI if available
250
+ if settings.openai_api_key and OpenAI is not None:
251
+ client = OpenAI(api_key=settings.openai_api_key)
252
+ try:
253
+ resp = client.chat.completions.create(
254
+ model="gpt-4o-mini",
255
+ messages=[{"role": "user", "content": prompt}],
256
+ temperature=0.3,
257
+ max_tokens=700,
258
+ )
259
+ return resp.choices[0].message.content
260
+ except Exception:
261
+ pass
262
+
263
+ # Fallback: short extractive-ish synthesis
264
+ # Compose a short paragraph from top contexts
265
+ extract = " ".join(contexts[:5])
266
+ return extract
267
+
268
  def query(self, query: str, top_k: int = 5) -> RetrievalOutput:
269
+ # Increase retrieval breadth by default so the LLM can synthesize across
270
+ # a broader set of feedback items (not only the top 5). This helps produce
271
+ # free-form analyst-style answers that consider more of the encoded data.
272
+ adjusted_k = max(top_k, 40)
273
+ results = self.retrieve(query, top_k=adjusted_k)
274
  contexts = [r.row[settings.text_column] for r in results]
275
+ # Use the new synthesizing free-form answer by default so responses are
276
+ # written as an experienced data analyst and synthesize across retrieved data.
277
+ summary = self.synthesize(query, results, contexts, max_contexts=adjusted_k)
278
  return RetrievalOutput(query=query, results=results, summary=summary)
279
 
280
  def answer(self, query: str, top_k: int = 5) -> RetrievalOutput:
app/static/app.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function checkServer() {
2
+ try {
3
+ const r = await fetch('/health', {method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({})});
4
+ if (!r.ok) throw new Error('no');
5
+ const j = await r.json();
6
+ document.getElementById('server-status').textContent = j.status || 'ok';
7
+ } catch (e) {
8
+ document.getElementById('server-status').textContent = 'ืœื ื–ืžื™ืŸ';
9
+ }
10
+ }
11
+
12
+ async function refreshHistory() {
13
+ try {
14
+ const r = await fetch('/history');
15
+ const j = await r.json();
16
+ const h = j.history || [];
17
+ const container = document.getElementById('history');
18
+ container.innerHTML = '';
19
+ if (h.length === 0) {
20
+ container.textContent = 'ืœื ื ืžืฆืื• ืฉืื™ืœืชื•ืช ืงื•ื“ืžื•ืช.';
21
+ return;
22
+ }
23
+ // Respect the UI toggle: only include example snippets in history when
24
+ // the user opts to show sources. History entries are simple objects: {query, response: {summary}}
25
+ const showSources = document.getElementById('show-sources') && document.getElementById('show-sources').checked;
26
+ h.slice().reverse().forEach(entry => {
27
+ const el = document.createElement('div');
28
+ el.className = 'card';
29
+ const q = document.createElement('div'); q.innerHTML = '<strong>ืฉืืœื”:</strong> ' + escapeHtml(entry.query);
30
+ const s = document.createElement('div'); s.innerHTML = '<strong>ืกื™ื›ื•ื:</strong> ' + (entry.response && entry.response.summary ? escapeHtml(entry.response.summary) : '');
31
+ const res = document.createElement('div');
32
+ // We intentionally do not render example records unless the server
33
+ // and client explicitly provide them. By default this is hidden.
34
+ el.appendChild(q); el.appendChild(s); el.appendChild(res);
35
+ container.appendChild(el);
36
+ });
37
+ } catch (e) {
38
+ console.error('history fetch failed', e);
39
+ }
40
+ }
41
+
42
+ function escapeHtml(unsafe) {
43
+ return unsafe
44
+ .replace(/&/g, "&amp;")
45
+ .replace(/</g, "&lt;")
46
+ .replace(/>/g, "&gt;")
47
+ .replace(/\"/g, "&quot;")
48
+ .replace(/'/g, "&#039;");
49
+ }
50
+
51
+ async function sendQuery() {
52
+ const q = document.getElementById('query').value;
53
+ const top_k = parseInt(document.getElementById('top_k').value || '5', 10);
54
+ const body = { query: q, top_k };
55
+ try {
56
+ const r = await fetch('/query', {method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)});
57
+ const j = await r.json();
58
+ // show last response (summary is primary)
59
+ document.getElementById('last-response').style.display = 'block';
60
+ document.getElementById('resp-summary').textContent = j.summary || '';
61
+ // we no longer show raw JSON or example records by default
62
+ await refreshHistory();
63
+ } catch (e) {
64
+ alert('ืฉื’ื™ืื” ื‘ืฉืœื™ื—ืช ื”ืฉืืœื” โ€” ื‘ื“ื•ืง ืืช ื”ืงื•ื ืกื•ืœ');
65
+ console.error(e);
66
+ }
67
+ }
68
+
69
+ async function clearHistory() {
70
+ try {
71
+ await fetch('/history/clear', {method: 'POST'});
72
+ await refreshHistory();
73
+ } catch (e) {
74
+ console.error('clear failed', e);
75
+ }
76
+ }
77
+
78
+ window.addEventListener('load', async () => {
79
+ document.getElementById('send').addEventListener('click', sendQuery);
80
+ document.getElementById('clear-history').addEventListener('click', clearHistory);
81
+ await checkServer();
82
+ await refreshHistory();
83
+ });
app/static/index.html ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="he">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Feedback RAG โ€” Frontend</title>
7
+ <style>
8
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', sans-serif; margin: 0; direction: rtl; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; color: #0b2545; }
9
+ .container { max-width: 1000px; margin: 24px auto; padding: 24px; }
10
+ h1 { color: white; text-shadow: 0 2px 4px rgba(0,0,0,0.2); margin: 0 0 24px 0; }
11
+ header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 0; }
12
+ textarea { width: 100%; height: 120px; font-size: 16px; padding:12px; border-radius:8px; border:2px solid #d0d7e6; font-family: inherit; }
13
+ textarea:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
14
+ input[type="number"] { padding: 8px; border-radius:6px; border:1px solid #d0d7e6; font-size:14px; }
15
+ button { padding: 10px 14px; margin-top: 8px; border-radius:6px; border: none; cursor: pointer; font-size:14px; font-weight:600; transition: all 0.2s; }
16
+ .primary { background: #0b63ff; color: white; }
17
+ .primary:hover { background: #0050cc; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(11,99,255,0.3); }
18
+ .muted { background: #eef3ff; color: #0b2545; }
19
+ .muted:hover { background: #dde6ff; }
20
+ .card { border-radius: 12px; padding: 20px; margin-top: 16px; background: white; box-shadow: 0 10px 30px rgba(0,0,0,0.15); }
21
+ .summary { font-size: 16px; line-height: 1.7; color: #073763; white-space: pre-wrap; word-wrap: break-word; }
22
+ .result { white-space: pre-wrap; }
23
+ header .title { font-size: 20px; margin:0 }
24
+ .history-list { margin-top: 20px; }
25
+ .small { color: #666; font-size: 12px; }
26
+ .controls { display:flex; gap:12px; align-items:center; margin-top:12px; flex-wrap: wrap; }
27
+ label { display:block; margin-bottom:6px; color: #0b2545; font-weight:500; }
28
+ .response-badge { display: inline-block; background: #e8f5e9; color: #2e7d32; padding: 4px 8px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <div class="container">
33
+ <header>
34
+ <h1>Feedback RAG โ€” ืžืžืฉืง</h1>
35
+ <div class="small">ืฉืจืช: <span id="server-status">...ื‘ื“ื™ืงื”</span></div>
36
+ </header>
37
+
38
+ <section class="card">
39
+ <label for="query">ืฉืืœื”</label>
40
+ <textarea id="query">ืชืกื•ื•ื’ ืืช ื”ืชืœื•ื ื•ืช 5 ืกื•ื’ื™ื</textarea>
41
+ <div>
42
+ <label for="top_k">ืžืกืคืจ ืชื•ืฆืื•ืช (top_k)</label>
43
+ <input id="top_k" type="number" value="5" min="1" max="50" style="width:80px;" />
44
+ </div>
45
+ <div style="margin-top:8px;">
46
+ <label><input type="checkbox" id="show-sources" /> ื”ืฆื’ ื“ื•ื’ืžืื•ืช (Sources)</label>
47
+ <span class="small" style="margin-left:12px;">ื‘ืจื™ืจืช ืžื—ื“ืœ: ืžื•ืกืชืจ โ€” ื™ื•ืฆื’ ืจืง ื”ืกื™ื›ื•ื ื”ืื ืœื™ื˜ื™</span>
48
+ </div>
49
+ <div style="display:flex;gap:8px;margin-top:12px;">
50
+ <button id="send" class="primary">๐Ÿ” ืฉืืœ</button>
51
+ <button id="clear-history" class="muted">๐Ÿ—‘๏ธ ื ืงื” ื”ื™ืกื˜ื•ืจื™ื”</button>
52
+ </div>
53
+ </section>
54
+
55
+ <section id="last-response" class="card" style="display:none;">
56
+ <h3>โœ“ ืชื’ื•ื‘ื” ืื ืœื™ื˜ื™ืช</h3>
57
+ <div id="resp-summary" class="summary"></div>
58
+ </section>
59
+
60
+ <section class="card history-list">
61
+ <h3>ื”ื™ืกื˜ื•ืจื™ื™ืช ืฉืืœื•ืช</h3>
62
+ <div id="history"></div>
63
+ </section>
64
+ </div>
65
+ <script src="/static/app.js"></script>
66
+ </body>
67
+ </html>
scripts/smoke_check.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Simple smoke check that hits the running FastAPI server root and /query.
3
+
4
+ This uses only the standard library so it should work in the project's venv.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.request
11
+
12
+
13
+ def get_root() -> str:
14
+ with urllib.request.urlopen("http://127.0.0.1:8000/") as resp:
15
+ return resp.read().decode("utf-8")
16
+
17
+
18
+ def post_query(q: str):
19
+ data = json.dumps({"query": q, "top_k": 5}).encode("utf-8")
20
+ req = urllib.request.Request("http://127.0.0.1:8000/query", data=data, headers={"Content-Type": "application/json"})
21
+ with urllib.request.urlopen(req, timeout=30) as resp:
22
+ return json.load(resp)
23
+
24
+
25
+ def main() -> None:
26
+ print("Checking http://127.0.0.1:8000/ ...")
27
+ try:
28
+ root = get_root()
29
+ print("Root response length:", len(root))
30
+ except Exception as e:
31
+ print("Failed to GET root:", e)
32
+ return
33
+
34
+ sample_q = "ืžื” ื”ื‘ืขื™ื•ืช ื”ืขื™ืงืจื™ื•ืช ืฉืžืฉืชืžืฉื™ื ืžืฆื™ื™ื ื™ื?"
35
+ print("Posting sample query to /query ...")
36
+ try:
37
+ resp = post_query(sample_q)
38
+ print("Query response keys:", list(resp.keys()))
39
+ print("Summary (truncated):\n", (resp.get("summary") or "(no summary)")[:800])
40
+ except Exception as e:
41
+ print("Failed to POST /query:", e)
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
uvicorn.log CHANGED
Binary files a/uvicorn.log and b/uvicorn.log differ