fix(avocet): normalize queue schema + bind to 0.0.0.0 for LAN access
- Add _item_id() (content hash) + _normalize() to map legacy JSONL fields (from_addr/account/no-id) to Vue schema (from/source/id) - All mutating endpoints now look up by _normalize(x)[id] — handles both stored-id (test fixtures) and content-hash (real data) transparently - Change uvicorn bind from 127.0.0.1 to 0.0.0.0 so LAN clients can connect
This commit is contained in:
parent
cd7bbd1dbf
commit
b54b2a711e
2 changed files with 36 additions and 13 deletions
47
app/api.py
47
app/api.py
|
|
@ -5,6 +5,7 @@ Endpoints and static file serving are added in subsequent tasks.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -60,6 +61,29 @@ def _append_jsonl(path: Path, record: dict) -> None:
|
||||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _item_id(item: dict) -> str:
|
||||||
|
"""Stable content-hash ID — matches label_tool.py _entry_key dedup logic."""
|
||||||
|
key = (item.get("subject", "") + (item.get("body", "") or "")[:100])
|
||||||
|
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(item: dict) -> dict:
|
||||||
|
"""Normalize JSONL item to the Vue frontend schema.
|
||||||
|
|
||||||
|
label_tool.py stores: subject, body, from_addr, date, account (no id).
|
||||||
|
The Vue app expects: id, subject, body, from, date, source.
|
||||||
|
Both old (from_addr/account) and new (from/source) field names are handled.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": item.get("id") or _item_id(item),
|
||||||
|
"subject": item.get("subject", ""),
|
||||||
|
"body": item.get("body", ""),
|
||||||
|
"from": item.get("from") or item.get("from_addr", ""),
|
||||||
|
"date": item.get("date", ""),
|
||||||
|
"source": item.get("source") or item.get("account", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Avocet API")
|
app = FastAPI(title="Avocet API")
|
||||||
|
|
||||||
# In-memory last-action store (single user, local tool — in-memory is fine)
|
# In-memory last-action store (single user, local tool — in-memory is fine)
|
||||||
|
|
@ -69,7 +93,7 @@ _last_action: dict | None = None
|
||||||
@app.get("/api/queue")
|
@app.get("/api/queue")
|
||||||
def get_queue(limit: int = Query(default=10, ge=1, le=50)):
|
def get_queue(limit: int = Query(default=10, ge=1, le=50)):
|
||||||
items = _read_jsonl(_queue_file())
|
items = _read_jsonl(_queue_file())
|
||||||
return {"items": items[:limit], "total": len(items)}
|
return {"items": [_normalize(x) for x in items[:limit]], "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
class LabelRequest(BaseModel):
|
class LabelRequest(BaseModel):
|
||||||
|
|
@ -81,13 +105,13 @@ class LabelRequest(BaseModel):
|
||||||
def post_label(req: LabelRequest):
|
def post_label(req: LabelRequest):
|
||||||
global _last_action
|
global _last_action
|
||||||
items = _read_jsonl(_queue_file())
|
items = _read_jsonl(_queue_file())
|
||||||
match = next((x for x in items if x["id"] == req.id), None)
|
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
||||||
record = {**match, "label": req.label,
|
record = {**match, "label": req.label,
|
||||||
"labeled_at": datetime.now(timezone.utc).isoformat()}
|
"labeled_at": datetime.now(timezone.utc).isoformat()}
|
||||||
_append_jsonl(_score_file(), record)
|
_append_jsonl(_score_file(), record)
|
||||||
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id])
|
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
|
||||||
_last_action = {"type": "label", "item": match, "label": req.label}
|
_last_action = {"type": "label", "item": match, "label": req.label}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
@ -100,10 +124,10 @@ class SkipRequest(BaseModel):
|
||||||
def post_skip(req: SkipRequest):
|
def post_skip(req: SkipRequest):
|
||||||
global _last_action
|
global _last_action
|
||||||
items = _read_jsonl(_queue_file())
|
items = _read_jsonl(_queue_file())
|
||||||
match = next((x for x in items if x["id"] == req.id), None)
|
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
||||||
reordered = [x for x in items if x["id"] != req.id] + [match]
|
reordered = [x for x in items if _normalize(x)["id"] != req.id] + [match]
|
||||||
_write_jsonl(_queue_file(), reordered)
|
_write_jsonl(_queue_file(), reordered)
|
||||||
_last_action = {"type": "skip", "item": match}
|
_last_action = {"type": "skip", "item": match}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -117,14 +141,14 @@ class DiscardRequest(BaseModel):
|
||||||
def post_discard(req: DiscardRequest):
|
def post_discard(req: DiscardRequest):
|
||||||
global _last_action
|
global _last_action
|
||||||
items = _read_jsonl(_queue_file())
|
items = _read_jsonl(_queue_file())
|
||||||
match = next((x for x in items if x["id"] == req.id), None)
|
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
raise HTTPException(404, f"Item {req.id!r} not found in queue")
|
||||||
record = {**match, "label": "__discarded__",
|
record = {**match, "label": "__discarded__",
|
||||||
"discarded_at": datetime.now(timezone.utc).isoformat()}
|
"discarded_at": datetime.now(timezone.utc).isoformat()}
|
||||||
_append_jsonl(_discarded_file(), record)
|
_append_jsonl(_discarded_file(), record)
|
||||||
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id])
|
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
|
||||||
_last_action = {"type": "discard", "item": match} # store ORIGINAL match, not enriched record
|
_last_action = {"type": "discard", "item": match}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,14 +177,13 @@ def delete_undo():
|
||||||
_write_jsonl(_queue_file(), [item] + items)
|
_write_jsonl(_queue_file(), [item] + items)
|
||||||
elif action["type"] == "skip":
|
elif action["type"] == "skip":
|
||||||
items = _read_jsonl(_queue_file())
|
items = _read_jsonl(_queue_file())
|
||||||
# Remove the item wherever it sits (guards against duplicate insertion),
|
item_id = _normalize(item)["id"]
|
||||||
# then prepend it to the front — restoring it to position 0.
|
items = [item] + [x for x in items if _normalize(x)["id"] != item_id]
|
||||||
items = [item] + [x for x in items if x["id"] != item["id"]]
|
|
||||||
_write_jsonl(_queue_file(), items)
|
_write_jsonl(_queue_file(), items)
|
||||||
|
|
||||||
# Clear AFTER all file operations succeed
|
# Clear AFTER all file operations succeed
|
||||||
_last_action = None
|
_last_action = None
|
||||||
return {"undone": {"type": action["type"], "item": item}}
|
return {"undone": {"type": action["type"], "item": _normalize(item)}}
|
||||||
|
|
||||||
|
|
||||||
# Label metadata — 10 labels matching label_tool.py
|
# Label metadata — 10 labels matching label_tool.py
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ case "$CMD" in
|
||||||
(cd web && npm run build) >> "$API_LOG" 2>&1
|
(cd web && npm run build) >> "$API_LOG" 2>&1
|
||||||
info "Starting FastAPI on port ${API_PORT}…"
|
info "Starting FastAPI on port ${API_PORT}…"
|
||||||
nohup "$PYTHON_UI" -m uvicorn app.api:app \
|
nohup "$PYTHON_UI" -m uvicorn app.api:app \
|
||||||
--host 127.0.0.1 --port "$API_PORT" \
|
--host 0.0.0.0 --port "$API_PORT" \
|
||||||
>> "$API_LOG" 2>&1 &
|
>> "$API_LOG" 2>&1 &
|
||||||
echo $! > "$API_PID_FILE"
|
echo $! > "$API_PID_FILE"
|
||||||
# Poll until port is actually bound (up to 10 s), not just process alive
|
# Poll until port is actually bound (up to 10 s), not just process alive
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue