Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/zh-CN/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,8 @@ makes that rebuild use confirmed counts only; it does not wait for the lock.

Manual `teamai recall feedback --positive <docId>` 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
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/confidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
18 changes: 18 additions & 0 deletions src/__tests__/search-index-local-votes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,22 @@ describe('index local votes (#1233)', () => {
expect((await indexed()).votes).toBe(3.6);
});

// #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);
});
});
21 changes: 14 additions & 7 deletions src/maintenance/confidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
10 changes: 7 additions & 3 deletions src/utils/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ interface VoteAggregation {

async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promise<VoteAggregation> {
const scores = new Map<string, number>();
const agg = new Map<string, { recalled: number; upvoted: number; lastRecalled: string }>();
const agg = new Map<string, { recalled: number; upvoted: number; lastRecalled: string; lastUpvoted: string }>();
const contents = voteScope
? await (await import('../votes.js')).readIndexVoteFiles(voteScope)
: await Promise.all((votesDir ? await listFiles(votesDir) : [])
Expand All @@ -372,20 +372,23 @@ 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 {
const votes = (parsed as unknown as UserVotes).votes;
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);
Expand All @@ -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,
}));
}

Expand Down
Loading