wop commited on
Commit
2f6de88
·
verified ·
1 Parent(s): 86f81e3

Upload 6 files

Browse files
Files changed (7) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +21 -0
  3. NOTES.md +40 -0
  4. main.py +673 -0
  5. requirements.txt +3 -0
  6. templates/index.html +967 -0
  7. templates/logo.png +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ templates/logo.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV DATA_DIR=/data
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ COPY requirements.txt /app/requirements.txt
14
+ RUN pip install --no-cache-dir -r /app/requirements.txt
15
+
16
+ COPY main.py /app/main.py
17
+ COPY templates /app/templates
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
NOTES.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Update Notes
2
+
3
+ Date: 2026-05-05
4
+
5
+ This update fixes answer-thread linking and adds runtime-only related answers without changing the data schema.
6
+
7
+ ### What changed
8
+
9
+ 1. Answer linking fix
10
+ - Frontend answer submissions now include the current question text.
11
+ - Backend answer attachment now checks whether the loaded conversation actually belongs to that question.
12
+ - If the user is answering a new question that was only semantically matched, a fresh conversation is created before saving the answer.
13
+
14
+ 2. Related answers
15
+ - `ask` now returns a `related` list built from the top semantic matches at runtime.
16
+ - Related answers are read-only in the UI and are shown as contextual help, not as part of the active thread.
17
+ - No database migration or schema change was introduced.
18
+
19
+ 3. UI and UX polish
20
+ - Added the app logo from `logo.png` to the top bar and page icon.
21
+ - Related answers are visually separated from the main thread to reduce confusion.
22
+ - The diffusion animation now reveals answer text progressively by token instead of using a single blur-to-clear block.
23
+
24
+ ### Behavior after update
25
+
26
+ - Exact match:
27
+ - shows the best answer
28
+ - shows other answers
29
+ - shows related answers
30
+
31
+ - Similar-but-not-same match:
32
+ - can still show existing knowledge
33
+ - does not write into the matched conversation
34
+ - creates a correct new conversation when the user submits an answer
35
+
36
+ ### Files changed
37
+
38
+ - `main.py`
39
+ - `index.html`
40
+ - `NOTES.md`
main.py ADDED
@@ -0,0 +1,673 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from urllib.parse import urlencode
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ APP_TITLE = "Human Intelligence"
17
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
18
+ CONVERSATIONS_FILE = DATA_DIR / "conversations.json"
19
+ EMBED_FILE = DATA_DIR / "embeddings.json"
20
+ PAGE_SIZE = 20
21
+
22
+ DEFAULT_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
23
+ DOCKER_TEMPLATES_DIR = Path("/app/templates")
24
+ TEMPLATES_DIR = DOCKER_TEMPLATES_DIR if DOCKER_TEMPLATES_DIR.exists() else DEFAULT_TEMPLATES_DIR
25
+
26
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
27
+ TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+ app = FastAPI(title=APP_TITLE)
30
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
31
+ app.mount("/templates", StaticFiles(directory=str(TEMPLATES_DIR)), name="templates")
32
+
33
+
34
+ # Utilities
35
+
36
+ def now_iso() -> str:
37
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
38
+
39
+
40
+ def read_json(path: Path, default: Any):
41
+ if not path.exists():
42
+ return default
43
+ try:
44
+ return json.loads(path.read_text(encoding="utf-8"))
45
+ except Exception:
46
+ return default
47
+
48
+
49
+ def write_json(path: Path, data: Any) -> None:
50
+ tmp = path.with_suffix(path.suffix + ".tmp")
51
+ tmp.write_text(
52
+ json.dumps(data, ensure_ascii=False, indent=2, default=str),
53
+ encoding="utf-8",
54
+ )
55
+ tmp.replace(path)
56
+
57
+
58
+ def get_client_id(request: Request, payload: dict | None = None) -> str:
59
+ header_value = request.headers.get("x-client-id", "").strip()
60
+ if header_value:
61
+ return header_value
62
+ if payload:
63
+ payload_value = str(payload.get("client_id", "")).strip()
64
+ if payload_value:
65
+ return payload_value
66
+ return "anon"
67
+
68
+
69
+ def anon_label(client_id: str) -> str:
70
+ return "Anonymous"
71
+
72
+
73
+ def clean_text(value: Any) -> str:
74
+ return str(value or "").strip()
75
+
76
+
77
+ def to_dt(value: Any) -> datetime:
78
+ text = clean_text(value)
79
+ if not text:
80
+ return datetime.fromtimestamp(0, tz=timezone.utc)
81
+ try:
82
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
83
+ except Exception:
84
+ return datetime.fromtimestamp(0, tz=timezone.utc)
85
+
86
+
87
+ def clamp_page(value: Any) -> int:
88
+ try:
89
+ page = int(value)
90
+ except Exception:
91
+ page = 1
92
+ return page if page > 0 else 1
93
+
94
+
95
+ def build_list_query(status: str, q: str, page: int) -> str:
96
+ params = {
97
+ "status": status,
98
+ "page": page,
99
+ }
100
+ if q:
101
+ params["q"] = q
102
+ return urlencode(params)
103
+
104
+
105
+ # Conversations CRUD
106
+
107
+ def load_conversations() -> list[dict[str, Any]]:
108
+ data = read_json(CONVERSATIONS_FILE, [])
109
+ if isinstance(data, dict) and "conversations" in data:
110
+ data = data["conversations"]
111
+ return data if isinstance(data, list) else []
112
+
113
+
114
+ def save_conversations(conversations: list[dict[str, Any]]) -> None:
115
+ write_json(CONVERSATIONS_FILE, conversations)
116
+
117
+
118
+ def normalize_version(version: dict[str, Any]) -> dict[str, Any]:
119
+ v = dict(version or {})
120
+ v.setdefault("id", uuid.uuid4().hex)
121
+ v.setdefault("text", "")
122
+ v.setdefault("author", "Anonymous")
123
+ v.setdefault("created_at", now_iso())
124
+ v.setdefault("votes", 0)
125
+ v.setdefault("votes_by_client", {})
126
+ if not isinstance(v["votes_by_client"], dict):
127
+ v["votes_by_client"] = {}
128
+ v["votes"] = int(v.get("votes", 0))
129
+ return v
130
+
131
+
132
+ def normalize_answer(answer: dict[str, Any]) -> dict[str, Any]:
133
+ a = dict(answer or {})
134
+ a.setdefault("id", uuid.uuid4().hex)
135
+ a.setdefault("versions", [])
136
+ a.setdefault("active_version", "")
137
+ a.setdefault("created_at", now_iso())
138
+ a.setdefault("updated_at", a["created_at"])
139
+
140
+ versions = [
141
+ normalize_version(v)
142
+ for v in a.get("versions", [])
143
+ if isinstance(v, dict)
144
+ ]
145
+ a["versions"] = versions
146
+
147
+ if versions:
148
+ version_ids = {v["id"] for v in versions}
149
+ if a["active_version"] not in version_ids:
150
+ a["active_version"] = max(
151
+ versions,
152
+ key=lambda v: (int(v.get("votes", 0)), str(v.get("created_at", ""))),
153
+ )["id"]
154
+ return a
155
+
156
+
157
+ def normalize_conversation(conversation: dict[str, Any]) -> dict[str, Any]:
158
+ c = dict(conversation or {})
159
+ c.setdefault("id", uuid.uuid4().hex)
160
+ c.setdefault("question", "")
161
+ c.setdefault("author", "Anonymous")
162
+ c.setdefault("created_at", now_iso())
163
+ c.setdefault("updated_at", c["created_at"])
164
+ c.setdefault("turns", [])
165
+ c.setdefault("answers", [])
166
+
167
+ turns: list[dict[str, Any]] = []
168
+ for turn in c.get("turns", []):
169
+ if not isinstance(turn, dict):
170
+ continue
171
+ t = dict(turn)
172
+ t.setdefault("id", uuid.uuid4().hex)
173
+ t.setdefault("role", "user")
174
+ t.setdefault("text", "")
175
+ t.setdefault("author", "Anonymous")
176
+ t.setdefault("ts", now_iso())
177
+ turns.append(t)
178
+ c["turns"] = turns
179
+
180
+ c["answers"] = [
181
+ normalize_answer(a)
182
+ for a in c.get("answers", [])
183
+ if isinstance(a, dict)
184
+ ]
185
+
186
+ if not c["turns"] and c["question"]:
187
+ c["turns"].append(
188
+ {
189
+ "id": uuid.uuid4().hex,
190
+ "role": "user",
191
+ "text": c["question"],
192
+ "author": c.get("author", "Anonymous"),
193
+ "ts": c["created_at"],
194
+ }
195
+ )
196
+ return c
197
+
198
+
199
+ def load_conversation(conversation_id: str) -> Optional[dict[str, Any]]:
200
+ if not conversation_id:
201
+ return None
202
+ for conv in load_conversations():
203
+ if str(conv.get("id")) == conversation_id:
204
+ return normalize_conversation(conv)
205
+ return None
206
+
207
+
208
+ def save_conversation(conversation: dict[str, Any]) -> dict[str, Any]:
209
+ conversation = normalize_conversation(conversation)
210
+ conversation["updated_at"] = now_iso()
211
+
212
+ conversations = [normalize_conversation(c) for c in load_conversations()]
213
+ replaced = False
214
+ for i, existing in enumerate(conversations):
215
+ if str(existing.get("id")) == str(conversation["id"]):
216
+ conversations[i] = conversation
217
+ replaced = True
218
+ break
219
+ if not replaced:
220
+ conversations.insert(0, conversation)
221
+
222
+ save_conversations(conversations)
223
+ return conversation
224
+
225
+
226
+ # Question summaries and search
227
+
228
+ def active_version(answer: dict[str, Any]) -> Optional[dict[str, Any]]:
229
+ versions = answer.get("versions", [])
230
+ if not versions:
231
+ return None
232
+ active_id = answer.get("active_version")
233
+ for version in versions:
234
+ if version.get("id") == active_id:
235
+ return version
236
+ return max(
237
+ versions,
238
+ key=lambda v: (int(v.get("votes", 0)), str(v.get("created_at", ""))),
239
+ )
240
+
241
+
242
+ def answer_score(answer: dict[str, Any]) -> tuple[int, str]:
243
+ av = active_version(answer)
244
+ if av is None:
245
+ return 0, str(answer.get("created_at", ""))
246
+ return int(av.get("votes", 0)), str(answer.get("created_at", ""))
247
+
248
+
249
+ def best_answer_payload(conversation: dict[str, Any]) -> Optional[dict[str, Any]]:
250
+ answers = conversation.get("answers", [])
251
+ if not answers:
252
+ return None
253
+ best = max(answers, key=answer_score)
254
+ av = active_version(best)
255
+ if av is None:
256
+ return None
257
+ return {
258
+ "answer_id": best["id"],
259
+ "version_id": av["id"],
260
+ "text": av["text"],
261
+ "votes": int(av.get("votes", 0)),
262
+ "author": av.get("author", "Anonymous"),
263
+ "created_at": av.get("created_at", ""),
264
+ }
265
+
266
+
267
+ def answer_text_length(conversation: dict[str, Any]) -> int:
268
+ best = best_answer_payload(conversation)
269
+ if not best:
270
+ return 0
271
+ return len(clean_text(best.get("text")))
272
+
273
+
274
+ def conversation_search_blob(conversation: dict[str, Any]) -> str:
275
+ parts = [clean_text(conversation.get("question"))]
276
+ for answer in conversation.get("answers", []):
277
+ for version in answer.get("versions", []):
278
+ parts.append(clean_text(version.get("text")))
279
+ return "\n".join(part for part in parts if part).casefold()
280
+
281
+
282
+ def matches_query(conversation: dict[str, Any], query: str) -> bool:
283
+ query = clean_text(query).casefold()
284
+ if not query:
285
+ return True
286
+ return query in conversation_search_blob(conversation)
287
+
288
+
289
+ def conversation_summary(conversation: dict[str, Any]) -> dict[str, Any]:
290
+ conversation = normalize_conversation(conversation)
291
+ answers = conversation.get("answers", [])
292
+ best = best_answer_payload(conversation)
293
+ return {
294
+ "id": conversation["id"],
295
+ "question": clean_text(conversation.get("question")),
296
+ "created_at": conversation.get("created_at", ""),
297
+ "updated_at": conversation.get("updated_at", ""),
298
+ "answer_count": len(answers),
299
+ "has_answers": bool(answers),
300
+ "best_answer": best,
301
+ "best_answer_length": answer_text_length(conversation),
302
+ "best_answer_preview": clean_text(best.get("text"))[:220] if best else "",
303
+ }
304
+
305
+
306
+ def sort_conversations_for_queue(
307
+ conversations: list[dict[str, Any]],
308
+ status: str,
309
+ query: str,
310
+ ) -> list[dict[str, Any]]:
311
+ status = clean_text(status).lower() or "unanswered"
312
+ query = clean_text(query)
313
+
314
+ if status == "unanswered" and not query:
315
+ return sorted(
316
+ conversations,
317
+ key=lambda c: to_dt(c.get("created_at") or c.get("updated_at")),
318
+ reverse=True,
319
+ )
320
+
321
+ def key(conversation: dict[str, Any]):
322
+ has_answers = bool(conversation.get("answers"))
323
+ best_length = answer_text_length(conversation) if has_answers else 0
324
+ updated = to_dt(conversation.get("updated_at") or conversation.get("created_at"))
325
+ return (
326
+ 0 if not has_answers else 1,
327
+ 0 if not has_answers else best_length,
328
+ -updated.timestamp(),
329
+ )
330
+
331
+ return sorted(conversations, key=key)
332
+
333
+
334
+ def list_questions(status: str = "unanswered", q: str = "", page: int = 1) -> dict[str, Any]:
335
+ status = clean_text(status).lower() or "unanswered"
336
+ if status not in {"unanswered", "answered", "all"}:
337
+ status = "unanswered"
338
+ q = clean_text(q)
339
+ page = clamp_page(page)
340
+
341
+ conversations = [normalize_conversation(c) for c in load_conversations()]
342
+
343
+ if status == "unanswered":
344
+ conversations = [c for c in conversations if not c.get("answers")]
345
+ elif status == "answered":
346
+ conversations = [c for c in conversations if c.get("answers")]
347
+
348
+ if q:
349
+ conversations = [c for c in conversations if matches_query(c, q)]
350
+
351
+ conversations = sort_conversations_for_queue(conversations, status=status, query=q)
352
+ total = len(conversations)
353
+ start = (page - 1) * PAGE_SIZE
354
+ end = start + PAGE_SIZE
355
+ items = [conversation_summary(c) for c in conversations[start:end]]
356
+
357
+ return {
358
+ "items": items,
359
+ "page": page,
360
+ "page_size": PAGE_SIZE,
361
+ "total": total,
362
+ "has_prev": page > 1,
363
+ "has_next": end < total,
364
+ "status": status,
365
+ "q": q,
366
+ }
367
+
368
+
369
+ # Write actions
370
+
371
+ def create_conversation(question: str, author: str = "Anonymous") -> dict[str, Any]:
372
+ question = question.strip()
373
+ now = now_iso()
374
+ conversation = {
375
+ "id": uuid.uuid4().hex,
376
+ "question": question,
377
+ "author": author,
378
+ "created_at": now,
379
+ "updated_at": now,
380
+ "turns": [
381
+ {
382
+ "id": uuid.uuid4().hex,
383
+ "role": "user",
384
+ "text": question,
385
+ "author": author,
386
+ "ts": now,
387
+ }
388
+ ],
389
+ "answers": [],
390
+ }
391
+ return save_conversation(conversation)
392
+
393
+
394
+ def add_answer(
395
+ conversation_id: str,
396
+ text: str,
397
+ author: str = "Anonymous",
398
+ question_if_new: str | None = None,
399
+ ) -> tuple[Optional[dict[str, Any]], str]:
400
+ text = text.strip()
401
+ if not text:
402
+ return None, "empty answer"
403
+
404
+ conversation = load_conversation(conversation_id)
405
+ if question_if_new:
406
+ if conversation is None or conversation.get("question") != question_if_new:
407
+ conversation = create_conversation(question_if_new, author)
408
+ elif conversation is None:
409
+ return None, "conversation not found"
410
+
411
+ now = now_iso()
412
+ version = normalize_version(
413
+ {
414
+ "text": text,
415
+ "author": author,
416
+ "created_at": now,
417
+ "votes": 0,
418
+ "votes_by_client": {},
419
+ }
420
+ )
421
+ answer = normalize_answer(
422
+ {
423
+ "id": uuid.uuid4().hex,
424
+ "versions": [version],
425
+ "active_version": version["id"],
426
+ "created_at": now,
427
+ "updated_at": now,
428
+ }
429
+ )
430
+
431
+ conversation["answers"].append(answer)
432
+ conversation["turns"].append(
433
+ {
434
+ "id": uuid.uuid4().hex,
435
+ "role": "assistant",
436
+ "text": text,
437
+ "author": author,
438
+ "answer_id": answer["id"],
439
+ "version_id": version["id"],
440
+ "ts": now,
441
+ }
442
+ )
443
+
444
+ conversation = save_conversation(conversation)
445
+ return conversation, "ok"
446
+
447
+
448
+ def propose_version(
449
+ conversation_id: str,
450
+ answer_id: str,
451
+ text: str,
452
+ author: str = "Anonymous",
453
+ ) -> tuple[Optional[dict[str, Any]], str]:
454
+ text = text.strip()
455
+ if not text:
456
+ return None, "empty proposal"
457
+
458
+ conversation = load_conversation(conversation_id)
459
+ if conversation is None:
460
+ return None, "conversation not found"
461
+
462
+ for answer in conversation["answers"]:
463
+ if str(answer.get("id")) != answer_id:
464
+ continue
465
+
466
+ now = now_iso()
467
+ version = normalize_version(
468
+ {
469
+ "text": text,
470
+ "author": author,
471
+ "created_at": now,
472
+ "votes": 0,
473
+ "votes_by_client": {},
474
+ }
475
+ )
476
+ answer["versions"].append(version)
477
+ answer["updated_at"] = now
478
+ conversation = save_conversation(conversation)
479
+ return conversation, "ok"
480
+
481
+ return None, "answer not found"
482
+
483
+
484
+ def vote_version(
485
+ conversation_id: str,
486
+ answer_id: str,
487
+ version_id: str,
488
+ client_id: str,
489
+ delta: int,
490
+ ) -> tuple[Optional[dict[str, Any]], str]:
491
+ conversation = load_conversation(conversation_id)
492
+ if conversation is None:
493
+ return None, "conversation not found"
494
+
495
+ delta = 1 if int(delta) >= 0 else -1
496
+
497
+ for answer in conversation["answers"]:
498
+ if str(answer.get("id")) != answer_id:
499
+ continue
500
+ for version in answer.get("versions", []):
501
+ if str(version.get("id")) != version_id:
502
+ continue
503
+
504
+ votes_by_client = version.setdefault("votes_by_client", {})
505
+ if not isinstance(votes_by_client, dict):
506
+ votes_by_client = {}
507
+ version["votes_by_client"] = votes_by_client
508
+
509
+ current = int(votes_by_client.get(client_id, 0))
510
+ if current == delta:
511
+ return conversation, "already_voted"
512
+
513
+ votes_by_client[client_id] = delta
514
+ version["votes"] = int(sum(int(v) for v in votes_by_client.values()))
515
+
516
+ if answer.get("versions"):
517
+ answer["active_version"] = max(
518
+ answer["versions"],
519
+ key=lambda v: (int(v.get("votes", 0)), str(v.get("created_at", ""))),
520
+ )["id"]
521
+
522
+ conversation = save_conversation(conversation)
523
+ return conversation, "ok"
524
+
525
+ return None, "version not found"
526
+
527
+
528
+ # Routes
529
+
530
+ @app.get("/", response_class=HTMLResponse)
531
+ def home(request: Request):
532
+ status = clean_text(request.query_params.get("status")).lower() or "unanswered"
533
+ if status not in {"unanswered", "answered", "all"}:
534
+ status = "unanswered"
535
+ q = clean_text(request.query_params.get("q"))
536
+ page = clamp_page(request.query_params.get("page", 1))
537
+ conversation_id = clean_text(request.query_params.get("conversation_id"))
538
+
539
+ list_state = list_questions(status=status, q=q, page=page)
540
+ detail = load_conversation(conversation_id) if conversation_id else None
541
+
542
+ init = {
543
+ "ok": True,
544
+ "client_id": get_client_id(request),
545
+ "view": "detail" if detail else "list",
546
+ "filters": {
547
+ "status": list_state["status"],
548
+ "q": list_state["q"],
549
+ "page": list_state["page"],
550
+ "page_size": list_state["page_size"],
551
+ },
552
+ "list": list_state,
553
+ "detail": detail,
554
+ "conversation_id": conversation_id,
555
+ "back_query": build_list_query(list_state["status"], list_state["q"], list_state["page"]),
556
+ }
557
+ return templates.TemplateResponse(
558
+ request,
559
+ "index.html",
560
+ {
561
+ "app_title": APP_TITLE,
562
+ "init_json": json.dumps(init, ensure_ascii=False),
563
+ },
564
+ )
565
+
566
+
567
+ @app.get("/logo.png")
568
+ def logo():
569
+ logo_path = TEMPLATES_DIR / "logo.png"
570
+ if logo_path.exists():
571
+ return FileResponse(logo_path)
572
+ return JSONResponse({"ok": False, "error": "logo not found"}, status_code=404)
573
+
574
+
575
+ @app.get("/health")
576
+ def health():
577
+ return {"ok": True}
578
+
579
+
580
+ @app.post("/api")
581
+ async def api(request: Request):
582
+ try:
583
+ payload = await request.json()
584
+ except Exception:
585
+ return JSONResponse({"ok": False, "error": "bad payload"})
586
+
587
+ action = clean_text(payload.get("action"))
588
+ client_id = get_client_id(request, payload)
589
+ author = anon_label(client_id)
590
+
591
+ if action == "init":
592
+ status = clean_text(payload.get("status")).lower() or "unanswered"
593
+ q = clean_text(payload.get("q"))
594
+ page = clamp_page(payload.get("page", 1))
595
+ conversation_id = clean_text(payload.get("conversation_id"))
596
+ return JSONResponse(
597
+ {
598
+ "ok": True,
599
+ "client_id": client_id,
600
+ "list": list_questions(status=status, q=q, page=page),
601
+ "detail": load_conversation(conversation_id) if conversation_id else None,
602
+ }
603
+ )
604
+
605
+ if action == "list_questions":
606
+ status = clean_text(payload.get("status")).lower() or "unanswered"
607
+ q = clean_text(payload.get("q"))
608
+ page = clamp_page(payload.get("page", 1))
609
+ return JSONResponse({"ok": True, **list_questions(status=status, q=q, page=page)})
610
+
611
+ if action in {"get_question_detail", "get_conversation"}:
612
+ conversation_id = clean_text(payload.get("conversation_id"))
613
+ conversation = load_conversation(conversation_id)
614
+ if conversation is None:
615
+ return JSONResponse({"ok": False, "error": "not found"})
616
+ return JSONResponse({"ok": True, "conversation": conversation})
617
+
618
+ if action in {"add_answer", "answer"}:
619
+ conversation_id = clean_text(payload.get("conversation_id"))
620
+ text = clean_text(payload.get("text"))
621
+ question = clean_text(payload.get("question")) or None
622
+
623
+ conversation, msg = add_answer(
624
+ conversation_id=conversation_id,
625
+ text=text,
626
+ author=author,
627
+ question_if_new=question,
628
+ )
629
+ if conversation is None:
630
+ return JSONResponse({"ok": False, "error": msg})
631
+ return JSONResponse({"ok": True, "conversation": conversation})
632
+
633
+ if action in {"propose_version", "propose"}:
634
+ conversation_id = clean_text(payload.get("conversation_id"))
635
+ answer_id = clean_text(payload.get("answer_id"))
636
+ text = clean_text(payload.get("text"))
637
+
638
+ conversation, msg = propose_version(
639
+ conversation_id=conversation_id,
640
+ answer_id=answer_id,
641
+ text=text,
642
+ author=author,
643
+ )
644
+ if conversation is None:
645
+ return JSONResponse({"ok": False, "error": msg})
646
+ return JSONResponse({"ok": True, "conversation": conversation})
647
+
648
+ if action in {"vote_version", "vote"}:
649
+ conversation_id = clean_text(payload.get("conversation_id"))
650
+ answer_id = clean_text(payload.get("answer_id"))
651
+ version_id = clean_text(payload.get("version_id"))
652
+ delta = int(payload.get("delta", 1))
653
+
654
+ conversation, msg = vote_version(
655
+ conversation_id=conversation_id,
656
+ answer_id=answer_id,
657
+ version_id=version_id,
658
+ client_id=client_id,
659
+ delta=delta,
660
+ )
661
+ if conversation is None:
662
+ return JSONResponse({"ok": False, "error": msg})
663
+ if msg == "already_voted":
664
+ return JSONResponse({"ok": False, "error": "already voted"})
665
+ return JSONResponse({"ok": True, "conversation": conversation})
666
+
667
+ return JSONResponse({"ok": False, "error": f"unknown action: {action}"})
668
+
669
+
670
+ if __name__ == "__main__":
671
+ import uvicorn
672
+
673
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ jinja2==3.1.5
templates/index.html ADDED
@@ -0,0 +1,967 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="/templates/logo.png" />
7
+ <title>{{ app_title }}</title>
8
+ <style>
9
+ :root {
10
+ --bg: #f4efe5;
11
+ --panel: #fffaf2;
12
+ --panel-2: #efe4d2;
13
+ --line: #d1bfa3;
14
+ --text: #2c2218;
15
+ --muted: #75624e;
16
+ --accent: #a44d2f;
17
+ --accent-soft: #f2d6c7;
18
+ --good: #3f6b3a;
19
+ --good-soft: #dcebd8;
20
+ --warn: #b07a17;
21
+ --warn-soft: #f9e8b9;
22
+ --bad: #8e3b2f;
23
+ --shadow: 0 18px 40px rgba(92, 62, 34, 0.14);
24
+ --radius: 18px;
25
+ --font: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
26
+ --mono: Consolas, "SFMono-Regular", monospace;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+ html, body { margin: 0; padding: 0; min-height: 100%; }
31
+ body {
32
+ font-family: var(--font);
33
+ color: var(--text);
34
+ background:
35
+ radial-gradient(circle at top right, rgba(164, 77, 47, 0.10), transparent 22%),
36
+ radial-gradient(circle at top left, rgba(176, 122, 23, 0.10), transparent 18%),
37
+ linear-gradient(180deg, #fbf6ee 0%, var(--bg) 100%);
38
+ }
39
+
40
+ a { color: inherit; }
41
+ button, input, textarea, select { font: inherit; }
42
+
43
+ .shell {
44
+ max-width: 1180px;
45
+ margin: 0 auto;
46
+ padding: 24px 18px 40px;
47
+ }
48
+
49
+ .topbar {
50
+ display: flex;
51
+ justify-content: space-between;
52
+ gap: 16px;
53
+ align-items: center;
54
+ padding: 18px 20px;
55
+ border: 1px solid var(--line);
56
+ border-radius: 24px;
57
+ background: rgba(255, 250, 242, 0.88);
58
+ backdrop-filter: blur(12px);
59
+ box-shadow: var(--shadow);
60
+ }
61
+
62
+ .brand {
63
+ display: flex;
64
+ gap: 14px;
65
+ align-items: center;
66
+ min-width: 0;
67
+ }
68
+
69
+ .brand img {
70
+ width: 42px;
71
+ height: 42px;
72
+ border-radius: 12px;
73
+ border: 1px solid var(--line);
74
+ object-fit: cover;
75
+ background: #fff;
76
+ }
77
+
78
+ .brand h1 {
79
+ margin: 0;
80
+ font-size: 1.15rem;
81
+ letter-spacing: -0.02em;
82
+ }
83
+
84
+ .brand p {
85
+ margin: 4px 0 0;
86
+ color: var(--muted);
87
+ font-size: 0.93rem;
88
+ }
89
+
90
+ .badge {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ gap: 6px;
94
+ border: 1px solid var(--line);
95
+ border-radius: 999px;
96
+ padding: 7px 12px;
97
+ font-size: 0.82rem;
98
+ color: var(--muted);
99
+ background: rgba(255,255,255,0.66);
100
+ white-space: nowrap;
101
+ }
102
+
103
+ .view {
104
+ margin-top: 20px;
105
+ display: none;
106
+ }
107
+
108
+ .view.active { display: block; }
109
+
110
+ .panel {
111
+ border: 1px solid var(--line);
112
+ border-radius: var(--radius);
113
+ background: var(--panel);
114
+ box-shadow: var(--shadow);
115
+ }
116
+
117
+ .list-layout {
118
+ display: grid;
119
+ gap: 18px;
120
+ }
121
+
122
+ .toolbar {
123
+ display: grid;
124
+ gap: 16px;
125
+ padding: 18px;
126
+ }
127
+
128
+ .toolbar-row {
129
+ display: flex;
130
+ flex-wrap: wrap;
131
+ gap: 10px;
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ }
135
+
136
+ .filters {
137
+ display: flex;
138
+ flex-wrap: wrap;
139
+ gap: 8px;
140
+ }
141
+
142
+ .pill {
143
+ border: 1px solid var(--line);
144
+ background: #fff;
145
+ color: var(--muted);
146
+ border-radius: 999px;
147
+ padding: 9px 14px;
148
+ cursor: pointer;
149
+ }
150
+
151
+ .pill.active {
152
+ background: var(--accent);
153
+ color: #fff;
154
+ border-color: var(--accent);
155
+ }
156
+
157
+ .search-form {
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ gap: 10px;
161
+ width: 100%;
162
+ }
163
+
164
+ .search-form input {
165
+ flex: 1 1 280px;
166
+ border: 1px solid var(--line);
167
+ border-radius: 14px;
168
+ padding: 12px 14px;
169
+ background: #fff;
170
+ min-width: 0;
171
+ }
172
+
173
+ .btn {
174
+ border: 1px solid var(--line);
175
+ background: #fff;
176
+ color: var(--text);
177
+ border-radius: 14px;
178
+ padding: 11px 16px;
179
+ cursor: pointer;
180
+ transition: transform 120ms ease, background 120ms ease;
181
+ }
182
+
183
+ .btn:hover { transform: translateY(-1px); }
184
+ .btn.primary {
185
+ background: var(--accent);
186
+ border-color: var(--accent);
187
+ color: #fff;
188
+ }
189
+
190
+ .meta-line {
191
+ color: var(--muted);
192
+ font-size: 0.9rem;
193
+ }
194
+
195
+ .queue-list {
196
+ display: grid;
197
+ gap: 14px;
198
+ padding: 18px;
199
+ }
200
+
201
+ .queue-item {
202
+ display: grid;
203
+ gap: 10px;
204
+ padding: 16px;
205
+ border: 1px solid var(--line);
206
+ border-radius: 16px;
207
+ background: #fff;
208
+ text-decoration: none;
209
+ transition: border-color 120ms ease, transform 120ms ease;
210
+ }
211
+
212
+ .queue-item:hover {
213
+ border-color: var(--accent);
214
+ transform: translateY(-1px);
215
+ }
216
+
217
+ .queue-head {
218
+ display: flex;
219
+ justify-content: space-between;
220
+ gap: 12px;
221
+ align-items: flex-start;
222
+ }
223
+
224
+ .queue-head h2 {
225
+ margin: 0;
226
+ font-size: 1.06rem;
227
+ line-height: 1.4;
228
+ }
229
+
230
+ .queue-tags {
231
+ display: flex;
232
+ gap: 8px;
233
+ flex-wrap: wrap;
234
+ }
235
+
236
+ .tag {
237
+ display: inline-flex;
238
+ align-items: center;
239
+ border-radius: 999px;
240
+ padding: 5px 10px;
241
+ font-size: 0.78rem;
242
+ border: 1px solid var(--line);
243
+ color: var(--muted);
244
+ background: #fffaf6;
245
+ }
246
+
247
+ .tag.unanswered {
248
+ border-color: #d7bf70;
249
+ background: var(--warn-soft);
250
+ color: #7c5b12;
251
+ }
252
+
253
+ .tag.answered {
254
+ border-color: #9cc199;
255
+ background: var(--good-soft);
256
+ color: #30502d;
257
+ }
258
+
259
+ .preview {
260
+ color: var(--muted);
261
+ line-height: 1.55;
262
+ white-space: pre-wrap;
263
+ word-break: break-word;
264
+ }
265
+
266
+ .pager {
267
+ display: flex;
268
+ justify-content: space-between;
269
+ gap: 12px;
270
+ align-items: center;
271
+ padding: 0 18px 18px;
272
+ flex-wrap: wrap;
273
+ }
274
+
275
+ .pager-actions {
276
+ display: flex;
277
+ gap: 10px;
278
+ }
279
+
280
+ .empty {
281
+ padding: 28px 18px 34px;
282
+ text-align: center;
283
+ color: var(--muted);
284
+ }
285
+
286
+ .detail-layout {
287
+ display: grid;
288
+ gap: 18px;
289
+ }
290
+
291
+ .detail-header {
292
+ padding: 18px;
293
+ display: grid;
294
+ gap: 12px;
295
+ }
296
+
297
+ .back-link {
298
+ color: var(--accent);
299
+ text-decoration: none;
300
+ font-weight: 600;
301
+ }
302
+
303
+ .question-box {
304
+ padding: 20px;
305
+ border: 1px solid var(--line);
306
+ border-radius: 18px;
307
+ background: #fff;
308
+ }
309
+
310
+ .question-box h2 {
311
+ margin: 0 0 10px;
312
+ font-size: 1.35rem;
313
+ line-height: 1.35;
314
+ }
315
+
316
+ .answer-form,
317
+ .answer-card,
318
+ .version-card {
319
+ padding: 18px;
320
+ border: 1px solid var(--line);
321
+ border-radius: 18px;
322
+ background: #fff;
323
+ }
324
+
325
+ .answer-form textarea,
326
+ .proposal-form textarea {
327
+ width: 100%;
328
+ min-height: 120px;
329
+ border: 1px solid var(--line);
330
+ border-radius: 14px;
331
+ padding: 12px 14px;
332
+ resize: vertical;
333
+ background: #fffdf9;
334
+ color: var(--text);
335
+ }
336
+
337
+ .section-title {
338
+ margin: 0;
339
+ font-size: 1.05rem;
340
+ }
341
+
342
+ .section-subtitle {
343
+ margin: 4px 0 0;
344
+ color: var(--muted);
345
+ font-size: 0.9rem;
346
+ }
347
+
348
+ .stack {
349
+ display: grid;
350
+ gap: 14px;
351
+ }
352
+
353
+ .answer-card {
354
+ display: grid;
355
+ gap: 14px;
356
+ }
357
+
358
+ .answer-top {
359
+ display: flex;
360
+ justify-content: space-between;
361
+ gap: 12px;
362
+ align-items: flex-start;
363
+ flex-wrap: wrap;
364
+ }
365
+
366
+ .answer-text,
367
+ .version-text {
368
+ line-height: 1.65;
369
+ white-space: pre-wrap;
370
+ word-break: break-word;
371
+ }
372
+
373
+ .vote-row {
374
+ display: flex;
375
+ gap: 8px;
376
+ flex-wrap: wrap;
377
+ align-items: center;
378
+ }
379
+
380
+ .vote-btn {
381
+ border: 1px solid var(--line);
382
+ background: #fffaf6;
383
+ border-radius: 12px;
384
+ padding: 8px 12px;
385
+ cursor: pointer;
386
+ }
387
+
388
+ .vote-btn.active-up {
389
+ border-color: #709c6c;
390
+ background: var(--good-soft);
391
+ color: var(--good);
392
+ }
393
+
394
+ .vote-btn.active-down {
395
+ border-color: #c79488;
396
+ background: #f8ddd8;
397
+ color: var(--bad);
398
+ }
399
+
400
+ .proposal-form {
401
+ display: grid;
402
+ gap: 10px;
403
+ padding-top: 6px;
404
+ }
405
+
406
+ .proposal-actions,
407
+ .form-actions {
408
+ display: flex;
409
+ gap: 10px;
410
+ flex-wrap: wrap;
411
+ }
412
+
413
+ .muted {
414
+ color: var(--muted);
415
+ font-size: 0.9rem;
416
+ }
417
+
418
+ .toast {
419
+ position: fixed;
420
+ right: 18px;
421
+ bottom: 18px;
422
+ padding: 12px 14px;
423
+ border-radius: 14px;
424
+ color: #fff;
425
+ background: rgba(44, 34, 24, 0.92);
426
+ box-shadow: var(--shadow);
427
+ opacity: 0;
428
+ transform: translateY(10px);
429
+ pointer-events: none;
430
+ transition: opacity 150ms ease, transform 150ms ease;
431
+ max-width: min(340px, calc(100vw - 36px));
432
+ }
433
+
434
+ .toast.show {
435
+ opacity: 1;
436
+ transform: translateY(0);
437
+ }
438
+
439
+ @media (max-width: 760px) {
440
+ .shell { padding: 16px 12px 28px; }
441
+ .topbar { padding: 14px; }
442
+ .queue-head,
443
+ .answer-top,
444
+ .toolbar-row,
445
+ .pager {
446
+ display: grid;
447
+ }
448
+ .search-form { grid-template-columns: 1fr; }
449
+ .btn, .pill { width: 100%; text-align: center; }
450
+ .pager-actions { width: 100%; }
451
+ .pager-actions .btn { flex: 1; }
452
+ }
453
+ </style>
454
+ </head>
455
+ <body>
456
+ <div class="shell">
457
+ <header class="topbar">
458
+ <div class="brand">
459
+ <img src="/templates/logo.png" alt="Logo" />
460
+ <div>
461
+ <h1>{{ app_title }} Queue</h1>
462
+ <p>Minimal project B for unanswered questions, answer versions, and voting.</p>
463
+ </div>
464
+ </div>
465
+ <div class="badge" id="statusBadge">Queue workspace</div>
466
+ </header>
467
+
468
+ <main>
469
+ <section id="listView" class="view">
470
+ <div class="list-layout">
471
+ <div class="panel toolbar">
472
+ <div class="toolbar-row">
473
+ <div>
474
+ <h2 class="section-title">Question queue</h2>
475
+ <p class="section-subtitle">Search stored questions and human answers with lightweight text matching.</p>
476
+ </div>
477
+ <div class="meta-line" id="listSummary"></div>
478
+ </div>
479
+ <div class="filters" id="statusFilters"></div>
480
+ <form id="searchForm" class="search-form">
481
+ <input id="searchInput" type="search" placeholder="Search questions, answers, or versions" />
482
+ <button class="btn primary" type="submit">Search</button>
483
+ <button class="btn" type="button" id="clearSearchBtn">Clear</button>
484
+ </form>
485
+ </div>
486
+
487
+ <div class="panel">
488
+ <div id="queueList" class="queue-list"></div>
489
+ <div id="emptyState" class="empty" hidden>No questions match this filter yet.</div>
490
+ <div class="pager">
491
+ <div class="meta-line" id="pagerInfo"></div>
492
+ <div class="pager-actions">
493
+ <button class="btn" id="prevPageBtn" type="button">Previous</button>
494
+ <button class="btn" id="nextPageBtn" type="button">Next</button>
495
+ </div>
496
+ </div>
497
+ </div>
498
+ </div>
499
+ </section>
500
+
501
+ <section id="detailView" class="view">
502
+ <div class="detail-layout">
503
+ <div class="panel detail-header">
504
+ <a id="backLink" class="back-link" href="/">← Back to question list</a>
505
+ <div class="question-box">
506
+ <div class="queue-tags" id="detailTags"></div>
507
+ <h2 id="detailQuestion"></h2>
508
+ <div class="meta-line" id="detailMeta"></div>
509
+ </div>
510
+ </div>
511
+
512
+ <div class="panel" style="padding:18px;">
513
+ <div class="answer-form stack">
514
+ <div>
515
+ <h3 class="section-title">Add an answer</h3>
516
+ <p class="section-subtitle">Write the first answer or add another answer version path for the community to vote on.</p>
517
+ </div>
518
+ <textarea id="newAnswerText" placeholder="Write a human answer for this question"></textarea>
519
+ <div class="form-actions">
520
+ <button class="btn primary" id="submitAnswerBtn" type="button">Save answer</button>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ <div class="stack" id="answersMount"></div>
526
+ </div>
527
+ </section>
528
+ </main>
529
+ </div>
530
+
531
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
532
+
533
+ <script>
534
+ window.__INIT__ = {{ init_json|safe }};
535
+ </script>
536
+ <script>
537
+ (() => {
538
+ const S = {
539
+ clientId: null,
540
+ filters: { status: "unanswered", q: "", page: 1, page_size: 20 },
541
+ list: null,
542
+ detail: null,
543
+ };
544
+
545
+ const $ = (id) => document.getElementById(id);
546
+ const esc = (value) => String(value ?? "")
547
+ .replace(/&/g, "&amp;")
548
+ .replace(/</g, "&lt;")
549
+ .replace(/>/g, "&gt;")
550
+ .replace(/"/g, "&quot;")
551
+ .replace(/'/g, "&#39;");
552
+
553
+ function nl2br(value) {
554
+ return esc(value).replace(/\n/g, "<br>");
555
+ }
556
+
557
+ function trimText(value) {
558
+ return String(value || "").trim();
559
+ }
560
+
561
+ function fmtDate(value) {
562
+ if (!value) return "unknown time";
563
+ const d = new Date(value);
564
+ if (Number.isNaN(d.getTime())) return value;
565
+ return d.toLocaleString();
566
+ }
567
+
568
+ function getClientId() {
569
+ const key = "hi_queue_client_id";
570
+ let id = localStorage.getItem(key);
571
+ if (!id) {
572
+ id = Math.random().toString(36).slice(2) + Date.now().toString(36);
573
+ localStorage.setItem(key, id);
574
+ }
575
+ return id;
576
+ }
577
+
578
+ function showToast(message) {
579
+ const toast = $("toast");
580
+ toast.textContent = message;
581
+ toast.classList.add("show");
582
+ clearTimeout(showToast._timer);
583
+ showToast._timer = setTimeout(() => toast.classList.remove("show"), 2400);
584
+ }
585
+
586
+ function buildQuery(extra = {}) {
587
+ const params = new URLSearchParams();
588
+ const merged = {
589
+ status: S.filters.status,
590
+ q: S.filters.q,
591
+ page: S.filters.page,
592
+ ...extra,
593
+ };
594
+ if (merged.status) params.set("status", merged.status);
595
+ if (merged.q) params.set("q", merged.q);
596
+ if (merged.page && Number(merged.page) > 1) params.set("page", String(merged.page));
597
+ if (merged.conversation_id) params.set("conversation_id", merged.conversation_id);
598
+ return params.toString();
599
+ }
600
+
601
+ function pushListUrl() {
602
+ const qs = buildQuery();
603
+ history.replaceState({}, "", qs ? `/?${qs}` : "/");
604
+ }
605
+
606
+ function pushDetailUrl(conversationId) {
607
+ const qs = buildQuery({ conversation_id: conversationId });
608
+ history.pushState({}, "", `/?${qs}`);
609
+ }
610
+
611
+ async function callAPI(action, payload = {}) {
612
+ const resp = await fetch("/api", {
613
+ method: "POST",
614
+ headers: {
615
+ "Content-Type": "application/json",
616
+ "X-Client-Id": S.clientId,
617
+ },
618
+ body: JSON.stringify({ action, client_id: S.clientId, ...payload }),
619
+ });
620
+ return resp.json();
621
+ }
622
+
623
+ function activeVersion(answer) {
624
+ const versions = Array.isArray(answer?.versions) ? answer.versions : [];
625
+ if (!versions.length) return null;
626
+ const byId = versions.find((v) => v.id === answer.active_version);
627
+ if (byId) return byId;
628
+ return [...versions].sort((a, b) => {
629
+ const voteDiff = Number(b.votes || 0) - Number(a.votes || 0);
630
+ if (voteDiff !== 0) return voteDiff;
631
+ return String(b.created_at || "").localeCompare(String(a.created_at || ""));
632
+ })[0];
633
+ }
634
+
635
+ function sortedAnswers(conversation) {
636
+ return [...(conversation?.answers || [])].sort((a, b) => {
637
+ const av = activeVersion(a);
638
+ const bv = activeVersion(b);
639
+ const voteDiff = Number(bv?.votes || 0) - Number(av?.votes || 0);
640
+ if (voteDiff !== 0) return voteDiff;
641
+ return String(b.created_at || "").localeCompare(String(a.created_at || ""));
642
+ });
643
+ }
644
+
645
+ function renderStatusFilters() {
646
+ const filters = [
647
+ ["unanswered", "Unanswered"],
648
+ ["answered", "Answered"],
649
+ ["all", "All"],
650
+ ];
651
+ $("statusFilters").innerHTML = filters.map(([value, label]) => `
652
+ <button class="pill ${S.filters.status === value ? "active" : ""}" type="button" data-status="${value}">${label}</button>
653
+ `).join("");
654
+
655
+ document.querySelectorAll("[data-status]").forEach((button) => {
656
+ button.onclick = () => {
657
+ S.filters.status = button.getAttribute("data-status");
658
+ S.filters.page = 1;
659
+ loadList();
660
+ };
661
+ });
662
+ }
663
+
664
+ function renderList() {
665
+ const list = S.list || { items: [], total: 0, page: 1, page_size: 20, has_next: false, has_prev: false };
666
+ $("listSummary").textContent = `${list.total} question${list.total === 1 ? "" : "s"} in queue`;
667
+ $("searchInput").value = S.filters.q || "";
668
+
669
+ const items = Array.isArray(list.items) ? list.items : [];
670
+ $("queueList").innerHTML = items.map((item) => {
671
+ const answered = item.has_answers;
672
+ const detailHref = `/?${buildQuery({ conversation_id: item.id })}`;
673
+ const preview = answered ? (item.best_answer_preview || "") : "No answer yet. Open this question to write the first answer.";
674
+ return `
675
+ <a class="queue-item" href="${detailHref}" data-open-detail="${item.id}">
676
+ <div class="queue-head">
677
+ <h2>${esc(item.question || "Untitled question")}</h2>
678
+ <div class="queue-tags">
679
+ <span class="tag ${answered ? "answered" : "unanswered"}">${answered ? "Answered" : "Unanswered"}</span>
680
+ <span class="tag">${Number(item.answer_count || 0)} answer${Number(item.answer_count || 0) === 1 ? "" : "s"}</span>
681
+ </div>
682
+ </div>
683
+ <div class="preview">${nl2br(preview)}</div>
684
+ <div class="meta-line">Updated ${fmtDate(item.updated_at || item.created_at)}${answered && item.best_answer ? ` · best answer votes ${Number(item.best_answer.votes || 0)}` : ""}</div>
685
+ </a>
686
+ `;
687
+ }).join("");
688
+
689
+ $("emptyState").hidden = items.length > 0;
690
+ $("pagerInfo").textContent = `Page ${list.page} · showing ${items.length} of ${list.total}`;
691
+ $("prevPageBtn").disabled = !list.has_prev;
692
+ $("nextPageBtn").disabled = !list.has_next;
693
+
694
+ document.querySelectorAll("[data-open-detail]").forEach((link) => {
695
+ link.onclick = async (event) => {
696
+ event.preventDefault();
697
+ await openDetail(link.getAttribute("data-open-detail"));
698
+ };
699
+ });
700
+ }
701
+
702
+ function renderDetail() {
703
+ const conversation = S.detail;
704
+ if (!conversation) return;
705
+ const answers = sortedAnswers(conversation);
706
+ const hasAnswers = answers.length > 0;
707
+
708
+ $("detailQuestion").textContent = conversation.question || "Untitled question";
709
+ $("detailMeta").textContent = `Created ${fmtDate(conversation.created_at)} · Updated ${fmtDate(conversation.updated_at)} · ${answers.length} answer${answers.length === 1 ? "" : "s"}`;
710
+ $("detailTags").innerHTML = `
711
+ <span class="tag ${hasAnswers ? "answered" : "unanswered"}">${hasAnswers ? "Answered" : "Unanswered"}</span>
712
+ <span class="tag">Question ID ${esc(conversation.id)}</span>
713
+ `;
714
+ $("backLink").href = `/?${buildQuery()}`;
715
+ $("newAnswerText").value = "";
716
+
717
+ $("answersMount").innerHTML = answers.length
718
+ ? answers.map((answer, index) => renderAnswerCard(answer, index)).join("")
719
+ : `<div class="panel empty">No answers yet. This question is ready for its first human answer.</div>`;
720
+
721
+ bindDetailHandlers();
722
+ }
723
+
724
+ function renderAnswerCard(answer, index) {
725
+ const current = activeVersion(answer);
726
+ const versions = [...(answer.versions || [])].sort((a, b) => {
727
+ const voteDiff = Number(b.votes || 0) - Number(a.votes || 0);
728
+ if (voteDiff !== 0) return voteDiff;
729
+ return String(b.created_at || "").localeCompare(String(a.created_at || ""));
730
+ });
731
+
732
+ const currentHtml = current ? `
733
+ <div class="answer-text">${nl2br(current.text || "")}</div>
734
+ <div class="vote-row">
735
+ ${renderVoteButton(answer.id, current.id, 1, `▲ ${Number(current.votes || 0)}`, current)}
736
+ ${renderVoteButton(answer.id, current.id, -1, "▼", current)}
737
+ </div>
738
+ ` : `<div class="muted">This answer has no active version yet.</div>`;
739
+
740
+ const versionCards = versions.map((version) => `
741
+ <div class="version-card">
742
+ <div class="answer-top">
743
+ <div>
744
+ <strong>${esc(version.author || "Anonymous")}</strong>
745
+ <div class="muted">${fmtDate(version.created_at)}${version.id === answer.active_version ? " · active version" : ""}</div>
746
+ </div>
747
+ <div class="queue-tags">
748
+ <span class="tag">Votes ${Number(version.votes || 0)}</span>
749
+ ${version.id === answer.active_version ? '<span class="tag answered">Active</span>' : ""}
750
+ </div>
751
+ </div>
752
+ <div class="version-text">${nl2br(version.text || "")}</div>
753
+ <div class="vote-row">
754
+ ${renderVoteButton(answer.id, version.id, 1, `▲ ${Number(version.votes || 0)}`, version)}
755
+ ${renderVoteButton(answer.id, version.id, -1, "▼", version)}
756
+ </div>
757
+ </div>
758
+ `).join("");
759
+
760
+ return `
761
+ <div class="panel" id="answer-${answer.id}">
762
+ <div class="answer-card">
763
+ <div class="answer-top">
764
+ <div>
765
+ <h3 class="section-title">Answer ${index + 1}</h3>
766
+ <p class="section-subtitle">Current community-selected version shown first. Historical versions stay searchable and voteable.</p>
767
+ </div>
768
+ <div class="queue-tags">
769
+ <span class="tag">${Number(answer.versions?.length || 0)} version${Number(answer.versions?.length || 0) === 1 ? "" : "s"}</span>
770
+ </div>
771
+ </div>
772
+ ${currentHtml}
773
+ <div class="proposal-form">
774
+ <label for="proposal-${answer.id}"><strong>Propose a new version</strong></label>
775
+ <textarea id="proposal-${answer.id}" placeholder="Write a better version for this answer"></textarea>
776
+ <div class="proposal-actions">
777
+ <button class="btn primary" type="button" data-propose="${answer.id}">Save version</button>
778
+ </div>
779
+ </div>
780
+ <div class="stack">${versionCards}</div>
781
+ </div>
782
+ </div>
783
+ `;
784
+ }
785
+
786
+ function renderVoteButton(answerId, versionId, delta, label, version) {
787
+ const voteMap = version?.votes_by_client || {};
788
+ const current = Number(voteMap[S.clientId] || 0);
789
+ const cls = delta === 1 ? (current === 1 ? "active-up" : "") : (current === -1 ? "active-down" : "");
790
+ return `<button class="vote-btn ${cls}" type="button" data-vote="${answerId}|${versionId}|${delta}">${label}</button>`;
791
+ }
792
+
793
+ function showView(name) {
794
+ $("listView").classList.toggle("active", name === "list");
795
+ $("detailView").classList.toggle("active", name === "detail");
796
+ $("statusBadge").textContent = name === "detail" ? "Question detail" : "Queue workspace";
797
+ }
798
+
799
+ async function loadList() {
800
+ const res = await callAPI("list_questions", {
801
+ status: S.filters.status,
802
+ q: S.filters.q,
803
+ page: S.filters.page,
804
+ });
805
+ if (!res.ok) {
806
+ showToast(res.error || "Could not load question list");
807
+ return;
808
+ }
809
+ S.list = res;
810
+ renderStatusFilters();
811
+ renderList();
812
+ showView("list");
813
+ pushListUrl();
814
+ }
815
+
816
+ async function loadDetail(conversationId, pushUrl = false) {
817
+ const res = await callAPI("get_question_detail", { conversation_id: conversationId });
818
+ if (!res.ok) {
819
+ showToast(res.error || "Question not found");
820
+ return false;
821
+ }
822
+ S.detail = res.conversation;
823
+ renderDetail();
824
+ showView("detail");
825
+ if (pushUrl) pushDetailUrl(conversationId);
826
+ return true;
827
+ }
828
+
829
+ async function openDetail(conversationId) {
830
+ await loadDetail(conversationId, true);
831
+ }
832
+
833
+ function bindListHandlers() {
834
+ $("searchForm").addEventListener("submit", async (event) => {
835
+ event.preventDefault();
836
+ S.filters.q = trimText($("searchInput").value);
837
+ S.filters.page = 1;
838
+ await loadList();
839
+ });
840
+
841
+ $("clearSearchBtn").onclick = async () => {
842
+ $("searchInput").value = "";
843
+ S.filters.q = "";
844
+ S.filters.page = 1;
845
+ await loadList();
846
+ };
847
+
848
+ $("prevPageBtn").onclick = async () => {
849
+ if (!S.list?.has_prev) return;
850
+ S.filters.page = Math.max(1, Number(S.filters.page || 1) - 1);
851
+ await loadList();
852
+ };
853
+
854
+ $("nextPageBtn").onclick = async () => {
855
+ if (!S.list?.has_next) return;
856
+ S.filters.page = Number(S.filters.page || 1) + 1;
857
+ await loadList();
858
+ };
859
+ }
860
+
861
+ function bindDetailHandlers() {
862
+ $("submitAnswerBtn").onclick = async () => {
863
+ const text = trimText($("newAnswerText").value);
864
+ if (!text) {
865
+ showToast("Write an answer first");
866
+ return;
867
+ }
868
+ const res = await callAPI("add_answer", {
869
+ conversation_id: S.detail.id,
870
+ text,
871
+ });
872
+ if (!res.ok) {
873
+ showToast(res.error || "Could not save answer");
874
+ return;
875
+ }
876
+ S.detail = res.conversation;
877
+ renderDetail();
878
+ showToast("Answer saved");
879
+ if (S.filters.status === "unanswered") {
880
+ await loadList();
881
+ showView("detail");
882
+ }
883
+ };
884
+
885
+ document.querySelectorAll("[data-propose]").forEach((button) => {
886
+ button.onclick = async () => {
887
+ const answerId = button.getAttribute("data-propose");
888
+ const box = $("proposal-" + answerId);
889
+ const text = trimText(box?.value);
890
+ if (!text) {
891
+ showToast("Write a version first");
892
+ return;
893
+ }
894
+ const res = await callAPI("propose_version", {
895
+ conversation_id: S.detail.id,
896
+ answer_id: answerId,
897
+ text,
898
+ });
899
+ if (!res.ok) {
900
+ showToast(res.error || "Could not save version");
901
+ return;
902
+ }
903
+ S.detail = res.conversation;
904
+ renderDetail();
905
+ showToast("Version saved");
906
+ };
907
+ });
908
+
909
+ document.querySelectorAll("[data-vote]").forEach((button) => {
910
+ button.onclick = async () => {
911
+ const [answerId, versionId, delta] = button.getAttribute("data-vote").split("|");
912
+ const res = await callAPI("vote_version", {
913
+ conversation_id: S.detail.id,
914
+ answer_id: answerId,
915
+ version_id: versionId,
916
+ delta: Number(delta),
917
+ });
918
+ if (!res.ok) {
919
+ showToast(res.error || "Vote failed");
920
+ return;
921
+ }
922
+ S.detail = res.conversation;
923
+ renderDetail();
924
+ showToast("Vote saved");
925
+ };
926
+ });
927
+ }
928
+
929
+ function initFromServer() {
930
+ const init = window.__INIT__ || {};
931
+ S.clientId = init.client_id || getClientId();
932
+ S.filters = {
933
+ ...S.filters,
934
+ ...(init.filters || {}),
935
+ };
936
+ S.list = init.list || null;
937
+ S.detail = init.detail || null;
938
+
939
+ renderStatusFilters();
940
+ renderList();
941
+ bindListHandlers();
942
+
943
+ if (S.detail) {
944
+ renderDetail();
945
+ showView("detail");
946
+ } else {
947
+ showView("list");
948
+ }
949
+ }
950
+
951
+ window.addEventListener("popstate", async () => {
952
+ const params = new URLSearchParams(window.location.search);
953
+ S.filters.status = params.get("status") || "unanswered";
954
+ S.filters.q = params.get("q") || "";
955
+ S.filters.page = Number(params.get("page") || 1);
956
+ const conversationId = params.get("conversation_id");
957
+ await loadList();
958
+ if (conversationId) {
959
+ await loadDetail(conversationId, false);
960
+ }
961
+ });
962
+
963
+ initFromServer();
964
+ })();
965
+ </script>
966
+ </body>
967
+ </html>
templates/logo.png ADDED

Git LFS Details

  • SHA256: 0ae1e3bebfec15710d33f03d51fc053f14d7ec6ff7b259ad6c8eeae3aedea022
  • Pointer size: 131 Bytes
  • Size of remote file: 695 kB