diff --git a/agents/experience_recorder.py b/agents/experience_recorder.py index 0fe5511..9e08dc9 100644 --- a/agents/experience_recorder.py +++ b/agents/experience_recorder.py @@ -338,6 +338,14 @@ def _score_entry(entry: dict, query_tokens: list[str]) -> int: Each token contributes 1, regardless of repetition — avoids a single noisy field dominating the score. Problem/model_type matches are weighted 2x. + + User annotations add a small boost on top of keyword score so highly-rated + or starred entries surface first when scores are close: + - rating (0-5): +rating + - starred (bool): +3 + The boost is additive rather than multiplicative so a 5-star entry with + zero keyword hits still can't outrank a direct-match entry — user ratings + guide, not override, relevance. """ if not query_tokens: return 0 @@ -354,6 +362,16 @@ def _score_entry(entry: dict, query_tokens: list[str]) -> int: score += 2 elif tok in body: score += 1 + + # User-annotation boost + try: + rating = int(entry.get("rating") or 0) + if 0 <= rating <= 5: + score += rating + except (TypeError, ValueError): + pass + if entry.get("starred"): + score += 3 return score diff --git a/ui/server.py b/ui/server.py index 35f4ea9..22e9779 100644 --- a/ui/server.py +++ b/ui/server.py @@ -42,7 +42,7 @@ try: from fastapi import FastAPI, HTTPException, Request - from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse + from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles import uvicorn except ImportError: @@ -1399,6 +1399,54 @@ async def get_experience(phase: str = "", limit: int = 20): } +@app.post("/api/experience/rate") +async def rate_experience(request: Request): + """Update an experience entry's user rating / star flag. + + Body: {"id": "", "rating": 0-5, "starred": bool} + Both rating and starred are optional — only provided fields are updated. + High-rated / starred entries get a ranking boost in get_relevant_experience. + """ + if not EXPERIENCE_LOG.exists(): + return JSONResponse({"ok": False, "error": "experience log missing"}, status_code=404) + + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "invalid JSON body"}, status_code=400) + + entry_id = str(body.get("id") or "").strip() + if not entry_id: + return JSONResponse({"ok": False, "error": "missing id"}, status_code=400) + + try: + data = json.loads(EXPERIENCE_LOG.read_text(encoding="utf-8")) + except Exception as exc: + return JSONResponse({"ok": False, "error": f"corrupted log: {exc}"}, status_code=500) + + entries = data.get("entries", []) + target = next((e for e in entries if str(e.get("id")) == entry_id), None) + if target is None: + return JSONResponse({"ok": False, "error": f"no entry with id={entry_id}"}, status_code=404) + + if "rating" in body: + try: + r = int(body["rating"]) + except (TypeError, ValueError): + return JSONResponse({"ok": False, "error": "rating must be int 0-5"}, status_code=400) + if not 0 <= r <= 5: + return JSONResponse({"ok": False, "error": "rating out of range"}, status_code=400) + target["rating"] = r + if "starred" in body: + target["starred"] = bool(body["starred"]) + + EXPERIENCE_LOG.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", + ) + return {"ok": True, "id": entry_id, + "rating": target.get("rating"), "starred": target.get("starred", False)} + + # ─────────────────────────────────────────────────────── entry ── if __name__ == "__main__": diff --git a/ui/static/index.html b/ui/static/index.html index a0da29a..00dc4b1 100644 --- a/ui/static/index.html +++ b/ui/static/index.html @@ -557,6 +557,24 @@ margin-top:12px;font-size:.78rem;color:var(--terracotta);cursor:pointer; background:none;border:none;padding:0;text-decoration:underline; } +.exp-rate-row{ + display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px; + border-top:1px dashed var(--border); +} +.exp-star-btn{ + background:none;border:none;cursor:pointer;font-size:1.1rem;color:#d1d5db; + padding:0 2px;line-height:1;transition:color .15s,transform .1s; +} +.exp-star-btn:hover{transform:scale(1.2)} +.exp-star-btn.on{color:#f59e0b} +.exp-fav-btn{ + background:none;border:1px solid var(--border);border-radius:12px; + padding:2px 10px;font-size:.72rem;cursor:pointer;color:var(--ink-light); + margin-left:auto;transition:all .15s; +} +.exp-fav-btn:hover{border-color:var(--terracotta);color:var(--terracotta)} +.exp-fav-btn.on{background:#fef3c7;border-color:#f59e0b;color:#92400e;font-weight:600} +.exp-rate-status{font-size:.72rem;color:var(--ink-light);margin-left:4px} @media(max-width:480px){ .hero{grid-template-columns:1fr} .page-tabs .page-tab{padding:5px 10px;font-size:.75rem} @@ -2441,6 +2459,24 @@

${escapeHtml(f.filename)} ★` + ).join(''); + const rateHtml = entryId ? ` +
+ 评分 + ${stars} + + +
` : ''; + return `
@@ -2451,10 +2487,32 @@

${escapeHtml(f.filename)} ${fieldHtml}

${moreHtml} + ${rateHtml}
`; }).join(''); } +async function rateExperience(id, rating, starred, btnEl) { + if (!id) return; + const body = {id}; + if (rating !== null) body.rating = rating; + if (starred !== null) body.starred = starred; + try { + const r = await fetch('/api/experience/rate', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (!r.ok || !d.ok) throw new Error(d.error || r.statusText); + // Refresh the view so star/fav state reflects the server + const activePhase = document.querySelector('.exp-filter-btn.active')?.dataset.phase || ''; + loadExperience(activePhase); + } catch (err) { + console.error('rate failed', err); + if (btnEl) btnEl.title = '保存失败:' + err.message; + } +} + function toggleExpDetail(id, btn) { const el = document.getElementById(id); const open = el.style.display === 'block';