wop commited on
Commit
5c2cf3a
·
verified ·
1 Parent(s): fc81709

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +472 -472
main.py CHANGED
@@ -1,472 +1,472 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- import uuid
6
- from datetime import datetime, timezone
7
- from pathlib import Path
8
- from typing import Any, Optional
9
-
10
- import numpy as np
11
- from fastapi import FastAPI, Request
12
- from fastapi.responses import HTMLResponse, JSONResponse
13
- from fastapi.staticfiles import StaticFiles
14
- from fastapi.templating import Jinja2Templates
15
-
16
- # Optional but recommended for similarity search.
17
- # If sentence-transformers is unavailable, the app still works with a fallback.
18
- try:
19
- from sentence_transformers import SentenceTransformer
20
- except Exception: # pragma: no cover
21
- SentenceTransformer = None # type: ignore
22
-
23
- try:
24
- from sklearn.metrics.pairwise import cosine_similarity
25
- except Exception: # pragma: no cover
26
- cosine_similarity = None # type: ignore
27
-
28
-
29
- APP_TITLE = "Human Intelligence"
30
- DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
31
- THREADS_DIR = DATA_DIR / "threads"
32
- INDEX_FILE = DATA_DIR / "index.json"
33
- EMBED_FILE = DATA_DIR / "embeddings.json"
34
- TEMPLATES_DIR = Path("/app/templates")
35
- SIMILARITY_THRESHOLD = float(os.environ.get("SIMILARITY_THRESHOLD", "0.82"))
36
-
37
- DATA_DIR.mkdir(parents=True, exist_ok=True)
38
- THREADS_DIR.mkdir(parents=True, exist_ok=True)
39
- TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
40
-
41
- app = FastAPI(title=APP_TITLE)
42
- templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
43
-
44
- # Serve any public assets you may add later.
45
- app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
46
-
47
- _embed_model = None
48
-
49
-
50
- # ---------------------------------------------------------------------
51
- # Utilities
52
- # ---------------------------------------------------------------------
53
- def now_iso() -> str:
54
- return datetime.now(timezone.utc).isoformat()
55
-
56
-
57
- def read_json(path: Path, default: Any):
58
- if not path.exists():
59
- return default
60
- try:
61
- return json.loads(path.read_text(encoding="utf-8"))
62
- except Exception:
63
- return default
64
-
65
-
66
- def write_json(path: Path, data: Any) -> None:
67
- tmp = path.with_suffix(path.suffix + ".tmp")
68
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
69
- tmp.replace(path)
70
-
71
-
72
- def get_username(request: Request, payload: dict | None = None) -> str:
73
- """
74
- Priority:
75
- 1) X-User header
76
- 2) JSON payload username
77
- 3) guest
78
- This keeps the app simple and deployment-friendly.
79
- """
80
- header_name = request.headers.get("x-user", "").strip()
81
- if header_name:
82
- return header_name
83
-
84
- if payload:
85
- p_name = str(payload.get("username", "")).strip()
86
- if p_name:
87
- return p_name
88
-
89
- return "Guest"
90
-
91
-
92
- # ---------------------------------------------------------------------
93
- # Embeddings / similarity
94
- # ---------------------------------------------------------------------
95
- def load_embed_model():
96
- global _embed_model
97
- if _embed_model is None:
98
- if SentenceTransformer is None:
99
- return None
100
- _embed_model = SentenceTransformer("all-MiniLM-L6-v2")
101
- return _embed_model
102
-
103
-
104
- def embed_text(text: str) -> list[float]:
105
- model = load_embed_model()
106
- if model is None:
107
- return []
108
-
109
- vec = model.encode(text, normalize_embeddings=True)
110
- return vec.tolist()
111
-
112
-
113
- def load_embed_index() -> dict:
114
- return read_json(EMBED_FILE, {})
115
-
116
-
117
- def save_embed_index(idx: dict) -> None:
118
- write_json(EMBED_FILE, idx)
119
-
120
-
121
- def find_similar_thread(question: str) -> Optional[tuple[str, float]]:
122
- idx = load_embed_index()
123
- if not idx:
124
- return None
125
-
126
- if cosine_similarity is None:
127
- return None
128
-
129
- q_vec = embed_text(question)
130
- if not q_vec:
131
- return None
132
-
133
- ids = list(idx.keys())
134
- vecs = np.array([idx[tid]["vector"] for tid in ids], dtype=float)
135
-
136
- if vecs.size == 0:
137
- return None
138
-
139
- sims = cosine_similarity(np.array(q_vec, dtype=float).reshape(1, -1), vecs)[0]
140
- best_i = int(np.argmax(sims))
141
- score = float(sims[best_i])
142
-
143
- if score >= SIMILARITY_THRESHOLD:
144
- return ids[best_i], score
145
- return None
146
-
147
-
148
- # ---------------------------------------------------------------------
149
- # Storage
150
- # ---------------------------------------------------------------------
151
- def load_index() -> list[dict]:
152
- return read_json(INDEX_FILE, [])
153
-
154
-
155
- def save_index(idx: list[dict]) -> None:
156
- write_json(INDEX_FILE, idx)
157
-
158
-
159
- def thread_path(tid: str) -> Path:
160
- return THREADS_DIR / f"{tid}.json"
161
-
162
-
163
- def load_thread(tid: str) -> Optional[dict]:
164
- return read_json(thread_path(tid), None)
165
-
166
-
167
- def save_thread(thread: dict) -> None:
168
- write_json(thread_path(thread["id"]), thread)
169
-
170
-
171
- def ensure_thread_schema(thread: dict) -> dict:
172
- thread.setdefault("id", uuid.uuid4().hex)
173
- thread.setdefault("question", "")
174
- thread.setdefault("author", "Guest")
175
- thread.setdefault("created_at", now_iso())
176
- thread.setdefault("messages", [])
177
- return thread
178
-
179
-
180
- # ---------------------------------------------------------------------
181
- # Content safety
182
- # ---------------------------------------------------------------------
183
- _toxic_pipe = None
184
-
185
- def load_safety_pipe():
186
- global _toxic_pipe
187
- if _toxic_pipe is None:
188
- try:
189
- from transformers import pipeline
190
- _toxic_pipe = pipeline(
191
- "text-classification",
192
- model="unitary/toxic-bert",
193
- device=-1,
194
- top_k=None,
195
- )
196
- except Exception:
197
- _toxic_pipe = False
198
- return _toxic_pipe
199
-
200
-
201
- def is_safe(text: str) -> tuple[bool, str]:
202
- if not text or not text.strip():
203
- return False, "empty"
204
-
205
- pipe = load_safety_pipe()
206
- if pipe is False:
207
- return True, "ok"
208
-
209
- try:
210
- results = pipe(text[:512])[0]
211
- for r in results:
212
- label = str(r.get("label", "")).lower()
213
- score = float(r.get("score", 0.0))
214
- if label != "non-toxic" and score > 0.70:
215
- return False, label or "unsafe"
216
- return True, "ok"
217
- except Exception:
218
- return True, "ok"
219
-
220
-
221
- # ---------------------------------------------------------------------
222
- # Core operations
223
- # ---------------------------------------------------------------------
224
- def create_thread(question: str, author: str) -> tuple[Optional[dict], str]:
225
- ok, reason = is_safe(question)
226
- if not ok:
227
- return None, f"Blocked: content flagged as {reason}."
228
-
229
- tid = uuid.uuid4().hex
230
- thread = {
231
- "id": tid,
232
- "question": question,
233
- "author": author,
234
- "created_at": now_iso(),
235
- "messages": [],
236
- }
237
- save_thread(thread)
238
-
239
- idx = load_index()
240
- idx.insert(
241
- 0,
242
- {
243
- "id": tid,
244
- "title": question[:120],
245
- "created_at": thread["created_at"],
246
- "author": author,
247
- "reply_count": 0,
248
- },
249
- )
250
- save_index(idx)
251
-
252
- emb_idx = load_embed_index()
253
- vec = embed_text(question)
254
- if vec:
255
- emb_idx[tid] = {"question": question, "vector": vec}
256
- save_embed_index(emb_idx)
257
-
258
- return thread, "ok"
259
-
260
-
261
- def add_answer(tid: str, text: str, author: str) -> tuple[Optional[dict], str]:
262
- ok, reason = is_safe(text)
263
- if not ok:
264
- return None, f"Blocked: content flagged as {reason}."
265
-
266
- thread = load_thread(tid)
267
- if thread is None:
268
- return None, "Thread not found."
269
-
270
- version = {
271
- "id": uuid.uuid4().hex,
272
- "text": text,
273
- "author": author,
274
- "created_at": now_iso(),
275
- "votes": 0,
276
- "voters": [],
277
- }
278
- message = {
279
- "id": uuid.uuid4().hex,
280
- "versions": [version],
281
- "active_version": version["id"],
282
- "created_at": version["created_at"],
283
- }
284
- thread["messages"].append(message)
285
- save_thread(thread)
286
-
287
- idx = load_index()
288
- for entry in idx:
289
- if entry["id"] == tid:
290
- entry["reply_count"] = len(thread["messages"])
291
- break
292
- save_index(idx)
293
-
294
- return thread, "ok"
295
-
296
-
297
- def propose_version(tid: str, msg_id: str, text: str, author: str) -> tuple[Optional[dict], str]:
298
- ok, reason = is_safe(text)
299
- if not ok:
300
- return None, f"Blocked: content flagged as {reason}."
301
-
302
- thread = load_thread(tid)
303
- if thread is None:
304
- return None, "Thread not found."
305
-
306
- for msg in thread["messages"]:
307
- if msg["id"] == msg_id:
308
- version = {
309
- "id": uuid.uuid4().hex,
310
- "text": text,
311
- "author": author,
312
- "created_at": now_iso(),
313
- "votes": 0,
314
- "voters": [],
315
- }
316
- msg["versions"].append(version)
317
- save_thread(thread)
318
- return thread, "ok"
319
-
320
- return None, "Message not found."
321
-
322
-
323
- def vote_version(tid: str, msg_id: str, version_id: str, username: str) -> tuple[Optional[dict], str]:
324
- thread = load_thread(tid)
325
- if thread is None:
326
- return None, "Thread not found."
327
-
328
- for msg in thread["messages"]:
329
- if msg["id"] == msg_id:
330
- for v in msg["versions"]:
331
- if v["id"] == version_id:
332
- if username in v["voters"]:
333
- return thread, "already_voted"
334
- v["votes"] += 1
335
- v["voters"].append(username)
336
- break
337
-
338
- msg["active_version"] = max(msg["versions"], key=lambda x: x["votes"])["id"]
339
- save_thread(thread)
340
- return thread, "ok"
341
-
342
- return None, "Message not found."
343
-
344
-
345
- def get_active_version(msg: dict) -> dict:
346
- vid = msg.get("active_version")
347
- for v in msg.get("versions", []):
348
- if v.get("id") == vid:
349
- return v
350
- return msg.get("versions", [{}])[0]
351
-
352
-
353
- # ---------------------------------------------------------------------
354
- # API
355
- # ---------------------------------------------------------------------
356
- @app.get("/", response_class=HTMLResponse)
357
- def home(request: Request):
358
- return templates.TemplateResponse(
359
- "index.html",
360
- {
361
- "request": request,
362
- "app_title": APP_TITLE,
363
- "init_json": json.dumps(
364
- {
365
- "ok": True,
366
- "username": get_username(request),
367
- "threads": load_index(),
368
- },
369
- ensure_ascii=False,
370
- ),
371
- },
372
- )
373
-
374
-
375
- @app.get("/api/init")
376
- def api_init(request: Request):
377
- return JSONResponse(
378
- {
379
- "ok": True,
380
- "username": get_username(request),
381
- "threads": load_index(),
382
- }
383
- )
384
-
385
-
386
- @app.post("/api")
387
- async def api(request: Request):
388
- try:
389
- payload = await request.json()
390
- except Exception:
391
- return JSONResponse({"ok": False, "error": "bad payload"})
392
-
393
- action = str(payload.get("action", ""))
394
- username = get_username(request, payload)
395
-
396
- if action == "init":
397
- return JSONResponse({"ok": True, "username": username, "threads": load_index()})
398
-
399
- if action == "list_threads":
400
- return JSONResponse({"ok": True, "threads": load_index()})
401
-
402
- if action == "get_thread":
403
- tid = str(payload.get("thread_id", ""))
404
- thread = load_thread(tid)
405
- if thread is None:
406
- return JSONResponse({"ok": False, "error": "not found"})
407
- return JSONResponse({"ok": True, "thread": thread})
408
-
409
- if action == "new_thread":
410
- if not username or username == "Guest":
411
- return JSONResponse({"ok": False, "error": "not signed in"})
412
- question = str(payload.get("question", "")).strip()
413
- if not question:
414
- return JSONResponse({"ok": False, "error": "empty question"})
415
- sim = find_similar_thread(question)
416
- thread, msg = create_thread(question, username)
417
- if thread is None:
418
- return JSONResponse({"ok": False, "error": msg})
419
- return JSONResponse({"ok": True, "thread": thread, "similar": sim is not None})
420
-
421
- if action == "add_answer":
422
- if not username or username == "Guest":
423
- return JSONResponse({"ok": False, "error": "not signed in"})
424
- tid = str(payload.get("thread_id", ""))
425
- text = str(payload.get("text", "")).strip()
426
- if not text:
427
- return JSONResponse({"ok": False, "error": "empty answer"})
428
- thread, msg = add_answer(tid, text, username)
429
- if thread is None:
430
- return JSONResponse({"ok": False, "error": msg})
431
- return JSONResponse({"ok": True, "thread": thread})
432
-
433
- if action == "propose":
434
- if not username or username == "Guest":
435
- return JSONResponse({"ok": False, "error": "not signed in"})
436
- tid = str(payload.get("thread_id", ""))
437
- msg_id = str(payload.get("msg_id", ""))
438
- text = str(payload.get("text", "")).strip()
439
- if not text:
440
- return JSONResponse({"ok": False, "error": "empty proposal"})
441
- thread, msg = propose_version(tid, msg_id, text, username)
442
- if thread is None:
443
- return JSONResponse({"ok": False, "error": msg})
444
- return JSONResponse({"ok": True, "thread": thread})
445
-
446
- if action == "vote":
447
- if not username or username == "Guest":
448
- return JSONResponse({"ok": False, "error": "not signed in"})
449
- tid = str(payload.get("thread_id", ""))
450
- msg_id = str(payload.get("msg_id", ""))
451
- version_id = str(payload.get("version_id", ""))
452
- thread, msg = vote_version(tid, msg_id, version_id, username)
453
- if thread is None:
454
- return JSONResponse({"ok": False, "error": msg})
455
- if msg == "already_voted":
456
- return JSONResponse({"ok": False, "error": "already voted"})
457
- return JSONResponse({"ok": True, "thread": thread})
458
-
459
- return JSONResponse({"ok": False, "error": f"unknown action: {action}"})
460
-
461
-
462
- # ---------------------------------------------------------------------
463
- # Optional healthcheck
464
- # ---------------------------------------------------------------------
465
- @app.get("/health")
466
- def health():
467
- return {"ok": True}
468
-
469
-
470
- if __name__ == "__main__":
471
- import uvicorn
472
- uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import numpy as np
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ # Optional but recommended for similarity search.
17
+ # If sentence-transformers is unavailable, the app still works with a fallback.
18
+ try:
19
+ from sentence_transformers import SentenceTransformer
20
+ except Exception: # pragma: no cover
21
+ SentenceTransformer = None # type: ignore
22
+
23
+ try:
24
+ from sklearn.metrics.pairwise import cosine_similarity
25
+ except Exception: # pragma: no cover
26
+ cosine_similarity = None # type: ignore
27
+
28
+
29
+ APP_TITLE = "Human Intelligence"
30
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
31
+ THREADS_DIR = DATA_DIR / "threads"
32
+ INDEX_FILE = DATA_DIR / "index.json"
33
+ EMBED_FILE = DATA_DIR / "embeddings.json"
34
+ TEMPLATES_DIR = Path("/app/templates")
35
+ SIMILARITY_THRESHOLD = float(os.environ.get("SIMILARITY_THRESHOLD", "0.82"))
36
+
37
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
38
+ THREADS_DIR.mkdir(parents=True, exist_ok=True)
39
+ TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
40
+
41
+ app = FastAPI(title=APP_TITLE)
42
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
43
+
44
+ # Serve any public assets you may add later.
45
+ app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
46
+
47
+ _embed_model = None
48
+
49
+
50
+ # ---------------------------------------------------------------------
51
+ # Utilities
52
+ # ---------------------------------------------------------------------
53
+ def now_iso() -> str:
54
+ return datetime.now(timezone.utc).isoformat()
55
+
56
+
57
+ def read_json(path: Path, default: Any):
58
+ if not path.exists():
59
+ return default
60
+ try:
61
+ return json.loads(path.read_text(encoding="utf-8"))
62
+ except Exception:
63
+ return default
64
+
65
+
66
+ def write_json(path: Path, data: Any) -> None:
67
+ tmp = path.with_suffix(path.suffix + ".tmp")
68
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
69
+ tmp.replace(path)
70
+
71
+
72
+ def get_username(request: Request, payload: dict | None = None) -> str:
73
+ """
74
+ Priority:
75
+ 1) X-User header
76
+ 2) JSON payload username
77
+ 3) guest
78
+ This keeps the app simple and deployment-friendly.
79
+ """
80
+ header_name = request.headers.get("x-user", "").strip()
81
+ if header_name:
82
+ return header_name
83
+
84
+ if payload:
85
+ p_name = str(payload.get("username", "")).strip()
86
+ if p_name:
87
+ return p_name
88
+
89
+ return "Guest"
90
+
91
+
92
+ # ---------------------------------------------------------------------
93
+ # Embeddings / similarity
94
+ # ---------------------------------------------------------------------
95
+ def load_embed_model():
96
+ global _embed_model
97
+ if _embed_model is None:
98
+ if SentenceTransformer is None:
99
+ return None
100
+ _embed_model = SentenceTransformer("all-MiniLM-L6-v2")
101
+ return _embed_model
102
+
103
+
104
+ def embed_text(text: str) -> list[float]:
105
+ model = load_embed_model()
106
+ if model is None:
107
+ return []
108
+
109
+ vec = model.encode(text, normalize_embeddings=True)
110
+ return vec.tolist()
111
+
112
+
113
+ def load_embed_index() -> dict:
114
+ return read_json(EMBED_FILE, {})
115
+
116
+
117
+ def save_embed_index(idx: dict) -> None:
118
+ write_json(EMBED_FILE, idx)
119
+
120
+
121
+ def find_similar_thread(question: str) -> Optional[tuple[str, float]]:
122
+ idx = load_embed_index()
123
+ if not idx:
124
+ return None
125
+
126
+ if cosine_similarity is None:
127
+ return None
128
+
129
+ q_vec = embed_text(question)
130
+ if not q_vec:
131
+ return None
132
+
133
+ ids = list(idx.keys())
134
+ vecs = np.array([idx[tid]["vector"] for tid in ids], dtype=float)
135
+
136
+ if vecs.size == 0:
137
+ return None
138
+
139
+ sims = cosine_similarity(np.array(q_vec, dtype=float).reshape(1, -1), vecs)[0]
140
+ best_i = int(np.argmax(sims))
141
+ score = float(sims[best_i])
142
+
143
+ if score >= SIMILARITY_THRESHOLD:
144
+ return ids[best_i], score
145
+ return None
146
+
147
+
148
+ # ---------------------------------------------------------------------
149
+ # Storage
150
+ # ---------------------------------------------------------------------
151
+ def load_index() -> list[dict]:
152
+ return read_json(INDEX_FILE, [])
153
+
154
+
155
+ def save_index(idx: list[dict]) -> None:
156
+ write_json(INDEX_FILE, idx)
157
+
158
+
159
+ def thread_path(tid: str) -> Path:
160
+ return THREADS_DIR / f"{tid}.json"
161
+
162
+
163
+ def load_thread(tid: str) -> Optional[dict]:
164
+ return read_json(thread_path(tid), None)
165
+
166
+
167
+ def save_thread(thread: dict) -> None:
168
+ write_json(thread_path(thread["id"]), thread)
169
+
170
+
171
+ def ensure_thread_schema(thread: dict) -> dict:
172
+ thread.setdefault("id", uuid.uuid4().hex)
173
+ thread.setdefault("question", "")
174
+ thread.setdefault("author", "Guest")
175
+ thread.setdefault("created_at", now_iso())
176
+ thread.setdefault("messages", [])
177
+ return thread
178
+
179
+
180
+ # ---------------------------------------------------------------------
181
+ # Content safety
182
+ # ---------------------------------------------------------------------
183
+ _toxic_pipe = None
184
+
185
+ def load_safety_pipe():
186
+ global _toxic_pipe
187
+ if _toxic_pipe is None:
188
+ try:
189
+ from transformers import pipeline
190
+ _toxic_pipe = pipeline(
191
+ "text-classification",
192
+ model="unitary/toxic-bert",
193
+ device=-1,
194
+ top_k=None,
195
+ )
196
+ except Exception:
197
+ _toxic_pipe = False
198
+ return _toxic_pipe
199
+
200
+
201
+ def is_safe(text: str) -> tuple[bool, str]:
202
+ if not text or not text.strip():
203
+ return False, "empty"
204
+
205
+ pipe = load_safety_pipe()
206
+ if pipe is False:
207
+ return True, "ok"
208
+
209
+ try:
210
+ results = pipe(text[:512])[0]
211
+ for r in results:
212
+ label = str(r.get("label", "")).lower()
213
+ score = float(r.get("score", 0.0))
214
+ if label != "non-toxic" and score > 0.70:
215
+ return False, label or "unsafe"
216
+ return True, "ok"
217
+ except Exception:
218
+ return True, "ok"
219
+
220
+
221
+ # ---------------------------------------------------------------------
222
+ # Core operations
223
+ # ---------------------------------------------------------------------
224
+ def create_thread(question: str, author: str) -> tuple[Optional[dict], str]:
225
+ ok, reason = is_safe(question)
226
+ if not ok:
227
+ return None, f"Blocked: content flagged as {reason}."
228
+
229
+ tid = uuid.uuid4().hex
230
+ thread = {
231
+ "id": tid,
232
+ "question": question,
233
+ "author": author,
234
+ "created_at": now_iso(),
235
+ "messages": [],
236
+ }
237
+ save_thread(thread)
238
+
239
+ idx = load_index()
240
+ idx.insert(
241
+ 0,
242
+ {
243
+ "id": tid,
244
+ "title": question[:120],
245
+ "created_at": thread["created_at"],
246
+ "author": author,
247
+ "reply_count": 0,
248
+ },
249
+ )
250
+ save_index(idx)
251
+
252
+ emb_idx = load_embed_index()
253
+ vec = embed_text(question)
254
+ if vec:
255
+ emb_idx[tid] = {"question": question, "vector": vec}
256
+ save_embed_index(emb_idx)
257
+
258
+ return thread, "ok"
259
+
260
+
261
+ def add_answer(tid: str, text: str, author: str) -> tuple[Optional[dict], str]:
262
+ ok, reason = is_safe(text)
263
+ if not ok:
264
+ return None, f"Blocked: content flagged as {reason}."
265
+
266
+ thread = load_thread(tid)
267
+ if thread is None:
268
+ return None, "Thread not found."
269
+
270
+ version = {
271
+ "id": uuid.uuid4().hex,
272
+ "text": text,
273
+ "author": author,
274
+ "created_at": now_iso(),
275
+ "votes": 0,
276
+ "voters": [],
277
+ }
278
+ message = {
279
+ "id": uuid.uuid4().hex,
280
+ "versions": [version],
281
+ "active_version": version["id"],
282
+ "created_at": version["created_at"],
283
+ }
284
+ thread["messages"].append(message)
285
+ save_thread(thread)
286
+
287
+ idx = load_index()
288
+ for entry in idx:
289
+ if entry["id"] == tid:
290
+ entry["reply_count"] = len(thread["messages"])
291
+ break
292
+ save_index(idx)
293
+
294
+ return thread, "ok"
295
+
296
+
297
+ def propose_version(tid: str, msg_id: str, text: str, author: str) -> tuple[Optional[dict], str]:
298
+ ok, reason = is_safe(text)
299
+ if not ok:
300
+ return None, f"Blocked: content flagged as {reason}."
301
+
302
+ thread = load_thread(tid)
303
+ if thread is None:
304
+ return None, "Thread not found."
305
+
306
+ for msg in thread["messages"]:
307
+ if msg["id"] == msg_id:
308
+ version = {
309
+ "id": uuid.uuid4().hex,
310
+ "text": text,
311
+ "author": author,
312
+ "created_at": now_iso(),
313
+ "votes": 0,
314
+ "voters": [],
315
+ }
316
+ msg["versions"].append(version)
317
+ save_thread(thread)
318
+ return thread, "ok"
319
+
320
+ return None, "Message not found."
321
+
322
+
323
+ def vote_version(tid: str, msg_id: str, version_id: str, username: str) -> tuple[Optional[dict], str]:
324
+ thread = load_thread(tid)
325
+ if thread is None:
326
+ return None, "Thread not found."
327
+
328
+ for msg in thread["messages"]:
329
+ if msg["id"] == msg_id:
330
+ for v in msg["versions"]:
331
+ if v["id"] == version_id:
332
+ if username in v["voters"]:
333
+ return thread, "already_voted"
334
+ v["votes"] += 1
335
+ v["voters"].append(username)
336
+ break
337
+
338
+ msg["active_version"] = max(msg["versions"], key=lambda x: x["votes"])["id"]
339
+ save_thread(thread)
340
+ return thread, "ok"
341
+
342
+ return None, "Message not found."
343
+
344
+
345
+ def get_active_version(msg: dict) -> dict:
346
+ vid = msg.get("active_version")
347
+ for v in msg.get("versions", []):
348
+ if v.get("id") == vid:
349
+ return v
350
+ return msg.get("versions", [{}])[0]
351
+
352
+
353
+ # ---------------------------------------------------------------------
354
+ # API
355
+ # ---------------------------------------------------------------------
356
+ @app.get("/", response_class=HTMLResponse)
357
+ def home(request: Request):
358
+ return templates.TemplateResponse(
359
+ "index.html",
360
+ {
361
+ "request": request,
362
+ "app_title": APP_TITLE,
363
+ "init_json": json.dumps(
364
+ {
365
+ "ok": True,
366
+ "username": get_username(request),
367
+ "threads": load_index(),
368
+ },
369
+ ensure_ascii=False,
370
+ ),
371
+ },
372
+ )
373
+
374
+
375
+ @app.get("/api/init")
376
+ def api_init(request: Request):
377
+ return JSONResponse(
378
+ {
379
+ "ok": True,
380
+ "username": get_username(request),
381
+ "threads": load_index(),
382
+ }
383
+ )
384
+
385
+
386
+ @app.post("/api")
387
+ async def api(request: Request):
388
+ try:
389
+ payload = await request.json()
390
+ except Exception:
391
+ return JSONResponse({"ok": False, "error": "bad payload"})
392
+
393
+ action = str(payload.get("action", ""))
394
+ username = get_username(request, payload)
395
+
396
+ if action == "init":
397
+ return JSONResponse({"ok": True, "username": username, "threads": load_index()})
398
+
399
+ if action == "list_threads":
400
+ return JSONResponse({"ok": True, "threads": load_index()})
401
+
402
+ if action == "get_thread":
403
+ tid = str(payload.get("thread_id", ""))
404
+ thread = load_thread(tid)
405
+ if thread is None:
406
+ return JSONResponse({"ok": False, "error": "not found"})
407
+ return JSONResponse({"ok": True, "thread": thread})
408
+
409
+ if action == "new_thread":
410
+ if not username or username == "Guest":
411
+ return JSONResponse({"ok": False, "error": "not signed in"})
412
+ question = str(payload.get("question", "")).strip()
413
+ if not question:
414
+ return JSONResponse({"ok": False, "error": "empty question"})
415
+ sim = find_similar_thread(question)
416
+ thread, msg = create_thread(question, username)
417
+ if thread is None:
418
+ return JSONResponse({"ok": False, "error": msg})
419
+ return JSONResponse({"ok": True, "thread": thread, "similar": sim is not None})
420
+
421
+ if action == "add_answer":
422
+ if not username or username == "Guest":
423
+ return JSONResponse({"ok": False, "error": "not signed in"})
424
+ tid = str(payload.get("thread_id", ""))
425
+ text = str(payload.get("text", "")).strip()
426
+ if not text:
427
+ return JSONResponse({"ok": False, "error": "empty answer"})
428
+ thread, msg = add_answer(tid, text, username)
429
+ if thread is None:
430
+ return JSONResponse({"ok": False, "error": msg})
431
+ return JSONResponse({"ok": True, "thread": thread})
432
+
433
+ if action == "propose":
434
+ if not username or username == "Guest":
435
+ return JSONResponse({"ok": False, "error": "not signed in"})
436
+ tid = str(payload.get("thread_id", ""))
437
+ msg_id = str(payload.get("msg_id", ""))
438
+ text = str(payload.get("text", "")).strip()
439
+ if not text:
440
+ return JSONResponse({"ok": False, "error": "empty proposal"})
441
+ thread, msg = propose_version(tid, msg_id, text, username)
442
+ if thread is None:
443
+ return JSONResponse({"ok": False, "error": msg})
444
+ return JSONResponse({"ok": True, "thread": thread})
445
+
446
+ if action == "vote":
447
+ if not username or username == "Guest":
448
+ return JSONResponse({"ok": False, "error": "not signed in"})
449
+ tid = str(payload.get("thread_id", ""))
450
+ msg_id = str(payload.get("msg_id", ""))
451
+ version_id = str(payload.get("version_id", ""))
452
+ thread, msg = vote_version(tid, msg_id, version_id, username)
453
+ if thread is None:
454
+ return JSONResponse({"ok": False, "error": msg})
455
+ if msg == "already_voted":
456
+ return JSONResponse({"ok": False, "error": "already voted"})
457
+ return JSONResponse({"ok": True, "thread": thread})
458
+
459
+ return JSONResponse({"ok": False, "error": f"unknown action: {action}"})
460
+
461
+
462
+ # ---------------------------------------------------------------------
463
+ # Optional healthcheck
464
+ # ---------------------------------------------------------------------
465
+ @app.get("/health")
466
+ def health():
467
+ return {"ok": True}
468
+
469
+
470
+ if __name__ == "__main__":
471
+ import uvicorn
472
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)