Compare commits

...

3 Commits

Author SHA1 Message Date
BOHA
302a0f8b3f chore(release): v2.4.15 - Odin tool-chip dedupe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:20:44 +02:00
BOHA
78f39e9f82 fix(odin): dedupe tool-trace chips — one per tool, warning only if all attempts failed
A failed call followed by a successful retry showed two identical chips
(one orange). Retries are loop mechanics: the trace now carries one entry
per tool with ok = delivered-at-least-once, for live chips and persisted
history alike (also keeps meta.tools under its 30-entry cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:20:43 +02:00
BOHA
949bf6c86f chore(release): v2.4.14 - Odin caller identity
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:16:06 +02:00
4 changed files with 113 additions and 6 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.13",
"version": "2.4.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.13",
"version": "2.4.15",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.13",
"version": "2.4.15",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -534,6 +534,76 @@ describe("agenticChat loop (SDK mocked)", () => {
expect(usageRows.length).toBe(2);
});
it("dedupes the tool trace: failed attempt + successful retry = one ok chip", async () => {
const self: AiAuthCtx = {
userId: 999999996,
roleName: "viewer",
permissions: ["attendance.record"],
};
createMock.mockReset();
createMock
.mockResolvedValueOnce({
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "toolu_a",
name: "get_attendance_summary",
input: { user_id: 1 }, // denied: someone else without manage
},
],
usage: { input_tokens: 10, output_tokens: 5 },
})
.mockResolvedValueOnce({
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "toolu_b",
name: "get_attendance_summary",
input: {}, // retry: own data, succeeds
},
],
usage: { input_tokens: 10, output_tokens: 5 },
})
.mockResolvedValueOnce({
stop_reason: "end_turn",
content: [{ type: "text", text: "Hotovo." }],
usage: { input_tokens: 10, output_tokens: 5 },
});
const res = await agenticChat(
[{ role: "user", content: "Moje docházka?" }],
self,
);
expect(res.toolTrace).toEqual([
{ name: "get_attendance_summary", label: "Docházka", ok: true },
]);
await prisma.ai_usage.deleteMany({
where: { user_id: self.userId, kind: "agent" },
});
});
it("injects the caller's identity into the system prompt", async () => {
createMock.mockReset();
createMock.mockResolvedValueOnce({
stop_reason: "end_turn",
content: [{ type: "text", text: "Ahoj." }],
usage: { input_tokens: 10, output_tokens: 5 },
});
await agenticChat([{ role: "user", content: "Kdo jsem?" }], {
userId: fixUserId,
roleName: "viewer",
permissions: ["attendance.record"],
});
const system: string = createMock.mock.calls[0][0].system;
expect(system).toContain(`${FIX.first} ${FIX.last}`);
expect(system).toContain(`user_id ${fixUserId}`);
// Cleanup the usage row this extra fixture-user turn recorded.
await prisma.ai_usage.deleteMany({
where: { user_id: fixUserId, kind: "agent" },
});
});
it("a user without permissions gets NO tools passed to the model", async () => {
createMock.mockReset();
createMock.mockResolvedValueOnce({

View File

@@ -257,12 +257,25 @@ export interface ToolTraceEntry {
// Phase 2a (read-only agent). The date is interpolated so "tento měsíc"
// questions resolve correctly — it changes once a day, which is fine because
// this prompt is small and we don't use prompt caching here.
function agentSystemPrompt(tools: Anthropic.Tool[]): string {
interface CallerIdentity {
name: string;
username: string;
userId: number;
}
function agentSystemPrompt(
tools: Anthropic.Tool[],
caller: CallerIdentity | null,
): string {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const hasFindEmployee = tools.some((t) => t.name === "find_employee");
return (
"Jsi Odin, asistent v interním systému české firmy (docházka, fakturace, nabídky, objednávky, projekty, sklad). " +
(caller
? `Přihlášený uživatel: ${caller.name} (user_id ${caller.userId}, username ${caller.username}). ` +
"Otázky v první osobě („moje docházka“, „kolik jsem najel“, „můj plán práce“) se týkají tohoto uživatele — použij jeho user_id a na identitu se nikdy neptej. "
: "") +
"Odpovídej VŽDY česky, stručně a věcně; částky formátuj s měnou. " +
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
(tools.length > 0
@@ -293,6 +306,19 @@ export async function agenticChat(
ctx: AiAuthCtx,
): Promise<{ reply: string; toolTrace: ToolTraceEntry[] }> {
const tools = toolDefinitionsFor(ctx);
// The model must know who it is talking to — first-person questions
// ("moje docházka") are unanswerable otherwise. PK lookup, negligible cost.
const me = await prisma.users.findUnique({
where: { id: ctx.userId },
select: { first_name: true, last_name: true, username: true },
});
const caller = me
? {
name: `${me.first_name} ${me.last_name}`.trim() || me.username,
username: me.username,
userId: ctx.userId,
}
: null;
const convo: Anthropic.MessageParam[] = messages.map((m) => ({
role: m.role,
content: m.content,
@@ -305,7 +331,7 @@ export async function agenticChat(
res = await client().messages.create({
model: AI_MODEL,
max_tokens: 2048,
system: agentSystemPrompt(tools),
system: agentSystemPrompt(tools, caller),
tools: tools.length > 0 ? tools : undefined,
messages: convo,
});
@@ -355,6 +381,17 @@ export async function agenticChat(
}
}
// One chip per tool: a failed attempt followed by a successful retry is
// loop mechanics, not information for the user. ok = the tool delivered
// data at least once; orange stays only when every attempt failed. Also
// keeps the persisted meta.tools comfortably under its 30-entry cap.
const dedupedTrace: ToolTraceEntry[] = [];
for (const t of trace) {
const seen = dedupedTrace.find((d) => d.name === t.name);
if (!seen) dedupedTrace.push({ ...t });
else seen.ok = seen.ok || t.ok;
}
const text = (res?.content ?? [])
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
@@ -373,7 +410,7 @@ export async function agenticChat(
} else if (!reply) {
reply = "Nepodařilo se získat odpověď, zkuste to prosím znovu.";
}
return { reply, toolTrace: trace };
return { reply, toolTrace: dedupedTrace };
}
export interface ExtractedInvoice {