From 4ad6eb5d1b98a5e1e25cacea4cd96c21568b0e94 Mon Sep 17 00:00:00 2001 From: dbdefault Date: Thu, 22 Oct 2026 15:07:46 +0800 Subject: [PATCH 1/2] fix(recall): use upvote time for confidence recency (#1278) Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/confidence.test.ts | 28 +++++++++++++++++++ .../search-index-local-votes.test.ts | 19 +++++++++++++ src/maintenance/confidence.ts | 21 +++++++++----- src/utils/search-index.ts | 10 +++++-- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/__tests__/confidence.test.ts b/src/__tests__/confidence.test.ts index a681b274f..9da8d6815 100644 --- a/src/__tests__/confidence.test.ts +++ b/src/__tests__/confidence.test.ts @@ -100,4 +100,32 @@ describe('computeConfidence', () => { expect(score).toBe(0.7); }); + + // #1278: a recent upvote with no recall must not be treated as 180 days stale. + it('uses lastUpvotedAt for recency when there is no recall (#1278)', () => { + const now = new Date().toISOString(); + const score = computeConfidence({ + recalledCount: 0, + upvotedCount: 1, + lastRecalledAt: '', + lastUpvotedAt: now, + }); + // base = 0.3, recency = 1.0, ratio = 1.0 + // confidence = 0.3*0.4 + 1.0*0.3 + 1.0*0.3 = 0.72 + expect(score).toBe(0.72); + }); + + it('takes recency from the later of recall and upvote timestamps (#1278)', () => { + const oldRecall = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString(); + const recentUpvote = new Date().toISOString(); + const score = computeConfidence({ + recalledCount: 1, + upvotedCount: 1, + lastRecalledAt: oldRecall, + lastUpvotedAt: recentUpvote, + }); + // base = 0.4, recency ≈ 1.0 (from upvote), ratio = 1.0 + // confidence = 0.4*0.4 + 1.0*0.3 + 1.0*0.3 = 0.76 + expect(score).toBe(0.76); + }); }); diff --git a/src/__tests__/search-index-local-votes.test.ts b/src/__tests__/search-index-local-votes.test.ts index 1d40e6ed8..cd2206166 100644 --- a/src/__tests__/search-index-local-votes.test.ts +++ b/src/__tests__/search-index-local-votes.test.ts @@ -178,4 +178,23 @@ describe('index local votes (#1233)', () => { expect(data.votes[docId].upvoted_count).toBe(0); expect(data.votes[docId].last_upvoted_at).toBeUndefined(); }); + + // #1278: upvoting a never-recalled doc must not apply the cold penalty. + it('keeps an upvote-only document hot when last_upvoted_at is recent (#1278)', async () => { + await confirmed({ + version: 2, + votes: { + [docId]: { + recalled_count: 0, + upvoted_count: 1, + last_recalled_at: '', + last_upvoted_at: date, + }, + }, + deltas: {}, + }); + const result = await indexed(); + expect(result.confidence).toBe(0.72); + expect(result.hotness).toBe(1); + }); }); diff --git a/src/maintenance/confidence.ts b/src/maintenance/confidence.ts index 483929b51..a15b3d4ae 100644 --- a/src/maintenance/confidence.ts +++ b/src/maintenance/confidence.ts @@ -19,21 +19,28 @@ export interface ConfidenceFactors { * * Formula: * base = min(1.0, recalled * 0.1 + upvoted * 0.3) - * recency = max(0, 1.0 - daysSinceLastRecall / 180) + * recency = max(0, 1.0 - daysSinceLastActivity / 180) + * where last activity is the later of lastRecalledAt and lastUpvotedAt (#1278) * ratio = min(1.0, upvoted / max(1, recalled)) * confidence = base * 0.4 + recency * 0.3 + ratio * 0.3 */ export function computeConfidence(factors: ConfidenceFactors): number { - const { recalledCount, upvotedCount, lastRecalledAt } = factors; + const { recalledCount, upvotedCount, lastRecalledAt, lastUpvotedAt } = factors; const base = Math.min(1.0, recalledCount * 0.1 + upvotedCount * 0.3); - let daysSinceRecall = 180; - if (lastRecalledAt) { - const elapsed = Date.now() - new Date(lastRecalledAt).getTime(); - daysSinceRecall = Math.max(0, elapsed / (1000 * 60 * 60 * 24)); + // A vote with no recall still signals recent interest; use the later timestamp. + const lastActivityAt = + lastRecalledAt && lastUpvotedAt + ? (lastRecalledAt > lastUpvotedAt ? lastRecalledAt : lastUpvotedAt) + : (lastRecalledAt || lastUpvotedAt || ''); + + let daysSinceActivity = 180; + if (lastActivityAt) { + const elapsed = Date.now() - new Date(lastActivityAt).getTime(); + daysSinceActivity = Math.max(0, elapsed / (1000 * 60 * 60 * 24)); } - const recency = Math.max(0, 1.0 - daysSinceRecall / 180); + const recency = Math.max(0, 1.0 - daysSinceActivity / 180); const ratio = Math.min(1.0, upvotedCount / Math.max(1, recalledCount)); diff --git a/src/utils/search-index.ts b/src/utils/search-index.ts index 3a35ec7a2..9ef5a9d45 100644 --- a/src/utils/search-index.ts +++ b/src/utils/search-index.ts @@ -350,7 +350,7 @@ interface VoteAggregation { async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promise { const scores = new Map(); - const agg = new Map(); + const agg = new Map(); const contents = voteScope ? await (await import('../votes.js')).readIndexVoteFiles(voteScope) : await Promise.all((votesDir ? await listFiles(votesDir) : []) @@ -372,12 +372,15 @@ async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promi const upvoted = entry.upvoted_count ?? 0; scores.set(docId, (scores.get(docId) ?? 0) + recalled * 0.3 + upvoted * 1.0); - const existing = agg.get(docId) ?? { recalled: 0, upvoted: 0, lastRecalled: '' }; + const existing = agg.get(docId) ?? { recalled: 0, upvoted: 0, lastRecalled: '', lastUpvoted: '' }; existing.recalled += recalled; existing.upvoted += upvoted; if (entry.last_recalled_at > existing.lastRecalled) { existing.lastRecalled = entry.last_recalled_at; } + if (entry.last_upvoted_at && entry.last_upvoted_at > existing.lastUpvoted) { + existing.lastUpvoted = entry.last_upvoted_at; + } agg.set(docId, existing); } } else { @@ -385,7 +388,7 @@ async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promi for (const [docId, entry] of Object.entries(votes)) { scores.set(docId, (scores.get(docId) ?? 0) + 1); - const existing = agg.get(docId) ?? { recalled: 0, upvoted: 0, lastRecalled: '' }; + const existing = agg.get(docId) ?? { recalled: 0, upvoted: 0, lastRecalled: '', lastUpvoted: '' }; existing.recalled += 1; if (entry.at > existing.lastRecalled) existing.lastRecalled = entry.at; agg.set(docId, existing); @@ -403,6 +406,7 @@ async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promi recalledCount: data.recalled, upvotedCount: data.upvoted, lastRecalledAt: data.lastRecalled, + lastUpvotedAt: data.lastUpvoted || undefined, })); } From 7dc5701b2c3a6aa7e7b49e6bb3e8f49172a43b5f Mon Sep 17 00:00:00 2001 From: dbdefault Date: Thu, 22 Oct 2026 16:17:44 +0800 Subject: [PATCH 2/2] docs(recall): an upvote now refreshes confidence recency (#1278) --- docs/guide/knowledge.md | 2 +- docs/guide/zh-CN/knowledge.md | 2 +- skill-data/core/references/troubleshooting.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guide/knowledge.md b/docs/guide/knowledge.md index abff0a6c2..c1191ee61 100644 --- a/docs/guide/knowledge.md +++ b/docs/guide/knowledge.md @@ -99,7 +99,7 @@ teamai recall maintenance --update-quality `--archive` puts aside learnings whose confidence is below 0.15. `--update-quality` writes a `.draft.md`. Rename it to `.md` when the draft is right. -Confidence is an activity score from 0 to 1, not a probability that the knowledge is correct or a task succeeded. Its upvote-to-recall ratio is capped at 1, so repeated positive feedback cannot exceed that factor's 30% weight or replace the recency factor. +Confidence is an activity score from 0 to 1, not a probability that the knowledge is correct or a task succeeded. Its upvote-to-recall ratio is capped at 1, so repeated positive feedback cannot exceed that factor's 30% weight. Recency uses the later of the last recall and the last upvote, so a fresh upvote also refreshes recency. ### Promoting Learnings diff --git a/docs/guide/zh-CN/knowledge.md b/docs/guide/zh-CN/knowledge.md index ee9e1d0f3..f39b349b0 100644 --- a/docs/guide/zh-CN/knowledge.md +++ b/docs/guide/zh-CN/knowledge.md @@ -99,7 +99,7 @@ teamai recall maintenance --update-quality `--archive` 收走置信度低于 0.15 的 learning。`--update-quality` 写出 `.draft.md`,改好后把文件名改成 `.md`。 -置信度是 0 到 1 的活动评分,不是知识正确或任务成功的概率。正反馈计数与召回计数的比值最高按 1 计算,因此重复正反馈不会超出这一项 30% 的权重,也不能代替近期召回这一项。 +置信度是 0 到 1 的活动评分,不是知识正确或任务成功的概率。正反馈计数与召回计数的比值最高按 1 计算,因此重复正反馈不会超出这一项 30% 的权重。近期度取最近一次召回与最近一次正反馈中较晚的时间,因此新的正反馈也会刷新近期度。 ### 晋升 Learnings diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index baa402038..f7843a6b8 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -372,7 +372,8 @@ makes that rebuild use confirmed counts only; it does not wait for the lock. Manual `teamai recall feedback --positive ` adds an upvote without adding a recall. Confidence caps the upvote-to-recall ratio at 1: more feedback cannot -exceed that factor's 30% weight or replace recency. This is an activity score, +exceed that factor's 30% weight. Recency uses the later of the last recall and +the last upvote, so a fresh upvote also refreshes recency. This is an activity score, not proof of knowledge correctness or task success. ## OpenCode V2 rejects the hooks plugin