From 5b29dbd684550eddb1344ae1b1da8c8f8cfca621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Wed, 9 Sep 2026 18:30:08 -0600 Subject: [PATCH] feat: translate pasted chat on-device before scam analysis --- apps/web/src/App.tsx | 47 +++++++++++--- apps/web/src/compositionRoot.ts | 9 ++- docs/performance-log.md | 2 +- packages/adapters-qvac/README.md | 2 +- packages/adapters-qvac/package.json | 4 +- .../adapters-qvac/src/QvacTranslateAdapter.ts | 55 +++++++++++----- .../__tests__/QvacTranslateAdapter.test.ts | 35 +++++++++++ .../src/createQvacTranslationClient.ts | 61 ++++++++++++++++++ packages/adapters-qvac/src/qvacRuntime.ts | 2 + .../use-cases/AnalyzeSendIntent.ts | 16 ++++- .../__tests__/AnalyzeSendIntent.test.ts | 27 ++++++++ .../TranslateAndAnalyzeMessage.test.ts | 62 +++++++++++++++++++ 12 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 packages/adapters-qvac/src/__tests__/QvacTranslateAdapter.test.ts create mode 100644 packages/adapters-qvac/src/createQvacTranslationClient.ts create mode 100644 packages/adapters-qvac/src/qvacRuntime.ts create mode 100644 packages/core/src/application/use-cases/__tests__/TranslateAndAnalyzeMessage.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8f54c0b..ddbc698 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,23 +1,49 @@ import { useState } from "react"; -import type { RiskAssessment } from "@custos/core"; -import { requiresFriction, requiresHardBlock } from "@custos/core"; -import { analyzeSendIntent } from "./compositionRoot.js"; +import type { ChatMessage, RiskAssessment, TranslatedChatMessage } from "@custos/core"; +import { languageTag, requiresFriction, requiresHardBlock } from "@custos/core"; +import { analyzeSendIntent, translateAndAnalyzeMessage } from "./compositionRoot.js"; + +function isTranslated(message: { text: string }): message is TranslatedChatMessage { + return "originalText" in message; +} + +function userLanguage() { + const tag = + typeof navigator !== "undefined" && navigator.language + ? navigator.language.slice(0, 2) + : "en"; + return languageTag(tag || "en"); +} export function App() { const [address, setAddress] = useState(""); const [amount, setAmount] = useState(""); const [context, setContext] = useState(""); const [assessment, setAssessment] = useState(null); + const [translated, setTranslated] = useState(null); const [busy, setBusy] = useState(false); async function handleCheck() { setBusy(true); + setTranslated(null); try { - const result = await analyzeSendIntent.execute({ - destination: { value: address, network: "tron" }, - amountUsdt: Number(amount) || 0, - context: context ? { text: context } : undefined, - }); + const pasted = context.trim(); + let contextPayload: ChatMessage | undefined; + let textMatches; + if (pasted) { + const ta = await translateAndAnalyzeMessage.execute(pasted, userLanguage()); + contextPayload = { text: ta.message.text, detectedLanguage: ta.message.detectedLanguage }; + textMatches = ta.matches; + setTranslated(isTranslated(ta.message) ? ta.message : null); + } + const result = await analyzeSendIntent.execute( + { + destination: { value: address, network: "tron" }, + amountUsdt: Number(amount) || 0, + context: contextPayload, + }, + { textMatches }, + ); setAssessment(result); } finally { setBusy(false); @@ -72,6 +98,11 @@ export function App() { }} > Risk level: {assessment.level} + {translated && ( +

+ Translated on-device from {translated.originalLanguage} to {translated.targetLanguage}. +

+ )}

{assessment.summary}

{requiresHardBlock(assessment.level) &&

This send would be blocked — WDK signing must not proceed.

} diff --git a/apps/web/src/compositionRoot.ts b/apps/web/src/compositionRoot.ts index f5ed7ad..ba6f7b8 100644 --- a/apps/web/src/compositionRoot.ts +++ b/apps/web/src/compositionRoot.ts @@ -1,5 +1,5 @@ -import { AnalyzeSendIntent, RecordUserDecision } from "@custos/core"; -import { QvacScamDetectionAdapter } from "@custos/adapters-qvac"; +import { AnalyzeSendIntent, RecordUserDecision, TranslateAndAnalyzeMessage } from "@custos/core"; +import { QvacScamDetectionAdapter, QvacTranslateAdapter } from "@custos/adapters-qvac"; import { LocalRiskListAdapter } from "@custos/adapters-p2p"; import { LocalAuditLogAdapter } from "@custos/adapters-storage"; @@ -10,8 +10,13 @@ import { LocalAuditLogAdapter } from "@custos/adapters-storage"; * is picked up — nothing outside this file should construct an adapter. */ const scamDetection = new QvacScamDetectionAdapter(); +const translation = new QvacTranslateAdapter(); const riskList = new LocalRiskListAdapter(); const auditLog = new LocalAuditLogAdapter(); export const analyzeSendIntent = new AnalyzeSendIntent({ scamDetection, riskList }); +export const translateAndAnalyzeMessage = new TranslateAndAnalyzeMessage({ + translation, + scamDetection, +}); export const recordUserDecision = new RecordUserDecision({ auditLog }); diff --git a/docs/performance-log.md b/docs/performance-log.md index 0a43100..1c70d44 100644 --- a/docs/performance-log.md +++ b/docs/performance-log.md @@ -18,7 +18,7 @@ submission device after `createQvacCompletionClient()` has loaded | Model | Quantization | Purpose | |---|---|---| | Llama 3.2 1B Instruct (`LLAMA_3_2_1B_INST_Q4_0`) | Q4_0 | scam-pattern classification (second pass after heuristics) | -| TranslatePsy (_variant pending issue #3_) | _pending_ | chat-context translation | +| Llama 3.2 1B Instruct via QVAC `translate()` | Q4_0 | chat-context translation (TranslatePsy slot; AfriSLM is `AFRICAN_4B_TRANSLATION_Q4_K_M`) | | VisionPsy (_variant pending stretch_) | _pending_ | screenshot OCR | ## Sample classification prompt diff --git a/packages/adapters-qvac/README.md b/packages/adapters-qvac/README.md index 1dc4e6b..61c8802 100644 --- a/packages/adapters-qvac/README.md +++ b/packages/adapters-qvac/README.md @@ -6,7 +6,7 @@ The only package allowed to import `@qvac/sdk`. Implements three ports from | Port | File | Status | |---|---|---| | `ScamDetectionPort` | `QvacScamDetectionAdapter.ts` | Heuristic pre-filter plus optional on-device QVAC completion pass. | -| `TranslationPort` | `QvacTranslateAdapter.ts` | Stub. Required for submission; this is the Track 02 load-bearing piece. | +| `TranslationPort` | `QvacTranslateAdapter.ts` | `detectLanguage` + `translate` via injected QVAC client (`createQvacTranslationClient`). Without a client, detect returns `en` so the Vite demo still analyzes. | | `OcrPort` | `QvacVisionAdapter.ts` | Stub. Stretch goal, pick up last. | Pinned SDK: `@qvac/sdk` `0.19.0`. Classification model: Llama 3.2 1B Instruct Q4_0 (`LLAMA_3_2_1B_INST_Q4_0`). diff --git a/packages/adapters-qvac/package.json b/packages/adapters-qvac/package.json index 9bca262..a9a1ffa 100644 --- a/packages/adapters-qvac/package.json +++ b/packages/adapters-qvac/package.json @@ -11,8 +11,8 @@ "import": "./dist/index.js" }, "./runtime": { - "types": "./dist/createQvacCompletionClient.d.ts", - "import": "./dist/createQvacCompletionClient.js" + "types": "./dist/qvacRuntime.d.ts", + "import": "./dist/qvacRuntime.js" } }, "scripts": { diff --git a/packages/adapters-qvac/src/QvacTranslateAdapter.ts b/packages/adapters-qvac/src/QvacTranslateAdapter.ts index a93c1dc..6758c3f 100644 --- a/packages/adapters-qvac/src/QvacTranslateAdapter.ts +++ b/packages/adapters-qvac/src/QvacTranslateAdapter.ts @@ -2,25 +2,52 @@ import type { TranslationPort } from "@custos/core"; import { languageTag, type LanguageTag } from "@custos/core"; import type { TranslatedChatMessage } from "@custos/core"; +export const FALLBACK_LANGUAGE: LanguageTag = languageTag("en"); + +export interface QvacTranslationClient { + detectLanguage(text: string): Promise; + translate(text: string, from: string, to: string): Promise; +} + /** - * TODO(sdk-integration): wire QVAC's TranslatePsy model. This port is - * central to the Track 02 story (see README "Where each Psy model is - * actually load-bearing") — do not stub this one out for the submission, - * it needs a real implementation, unlike OcrPort which is a stretch goal. + * TranslationPort over QVAC `translate()` (and a detect pass). The real SDK + * client is created by `createQvacTranslationClient` and must not be imported + * from Vite. Without a client, `detectLanguage` returns English so the send + * flow still analyzes the original paste instead of throwing. */ export class QvacTranslateAdapter implements TranslationPort { - async detectLanguage(_text: string): Promise { - throw new Error( - "QvacTranslateAdapter.detectLanguage not implemented — wire TranslatePsy per qvac.tether.io", - ); + private lastDetect: { text: string; lang: LanguageTag } | undefined; + + constructor(private readonly client?: QvacTranslationClient) {} + + async detectLanguage(text: string): Promise { + if (!this.client) return FALLBACK_LANGUAGE; + const raw = (await this.client.detectLanguage(text)).trim().toLowerCase(); + const code = raw.slice(0, 2); + const lang = languageTag(code.length === 2 ? code : "en"); + this.lastDetect = { text, lang }; + return lang; } async translate(text: string, targetLanguage: LanguageTag): Promise { - throw new Error( - `QvacTranslateAdapter.translate not implemented — wire TranslatePsy per qvac.tether.io (target=${targetLanguage}, textLength=${text.length})`, - ); + const detected = + this.lastDetect?.text === text ? this.lastDetect.lang : await this.detectLanguage(text); + if (!this.client) { + return { + text, + detectedLanguage: targetLanguage, + originalText: text, + originalLanguage: detected, + targetLanguage, + }; + } + const translated = await this.client.translate(text, detected, targetLanguage); + return { + text: translated, + detectedLanguage: targetLanguage, + originalText: text, + originalLanguage: detected, + targetLanguage, + }; } } - -// Convenience for callers who need a placeholder tag during local dev without a device. -export const FALLBACK_LANGUAGE: LanguageTag = languageTag("en"); diff --git a/packages/adapters-qvac/src/__tests__/QvacTranslateAdapter.test.ts b/packages/adapters-qvac/src/__tests__/QvacTranslateAdapter.test.ts new file mode 100644 index 0000000..821c32b --- /dev/null +++ b/packages/adapters-qvac/src/__tests__/QvacTranslateAdapter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { languageTag } from "@custos/core"; +import { FALLBACK_LANGUAGE, QvacTranslateAdapter } from "../QvacTranslateAdapter.js"; + +describe("QvacTranslateAdapter", () => { + it("returns the fallback language when no QVAC client is wired, so the web demo still analyzes", async () => { + const adapter = new QvacTranslateAdapter(); + expect(await adapter.detectLanguage("hola")).toBe(FALLBACK_LANGUAGE); + }); + + it("detectLanguage and translate both call the injected QVAC client", async () => { + const calls: string[] = []; + const adapter = new QvacTranslateAdapter({ + detectLanguage: async (text) => { + calls.push(`detect:${text}`); + return "es"; + }, + translate: async (text, from, to) => { + calls.push(`translate:${from}->${to}:${text}`); + return "guaranteed 30% weekly returns"; + }, + }); + + const detected = await adapter.detectLanguage("rendimientos semanales"); + expect(detected).toBe(languageTag("es")); + + const translated = await adapter.translate("rendimientos semanales", languageTag("en")); + expect(translated.text).toBe("guaranteed 30% weekly returns"); + expect(translated.originalText).toBe("rendimientos semanales"); + expect(translated.originalLanguage).toBe(languageTag("es")); + expect(translated.targetLanguage).toBe(languageTag("en")); + expect(calls[0]).toBe("detect:rendimientos semanales"); + expect(calls.some((c) => c.startsWith("translate:es->en:"))).toBe(true); + }); +}); diff --git a/packages/adapters-qvac/src/createQvacTranslationClient.ts b/packages/adapters-qvac/src/createQvacTranslationClient.ts new file mode 100644 index 0000000..953b3ed --- /dev/null +++ b/packages/adapters-qvac/src/createQvacTranslationClient.ts @@ -0,0 +1,61 @@ +import { completion, LLAMA_3_2_1B_INST_Q4_0, loadModel, translate, unloadModel } from "@qvac/sdk"; +import type { QvacTranslationClient } from "./QvacTranslateAdapter.js"; + +export const QVAC_TRANSLATE_MODEL_NAME = "Llama 3.2 1B Instruct"; +export const QVAC_TRANSLATE_MODEL_QUANTIZATION = "Q4_0"; + +/** + * On-device QVAC `translate()` + a detect pass via `completion()`. + * TranslatePsy-AfriSLM is in the registry as `AFRICAN_4B_TRANSLATION_Q4_K_M`; + * this factory uses Llama 3.2 1B so the ES/EN/ZH pig-butchering demo actually + * translates. Swap `modelSrc` when the demo language set is African. + * + * Do not import this file from Vite. + */ +export async function createQvacTranslationClient(): Promise<{ + client: QvacTranslationClient; + dispose: () => Promise; +}> { + const modelId = await loadModel({ + modelSrc: LLAMA_3_2_1B_INST_Q4_0, + modelType: "llm", + }); + + const client: QvacTranslationClient = { + async detectLanguage(text: string) { + const result = completion({ + modelId, + history: [ + { + role: "user", + content: `Reply with only the ISO 639-1 language code of this text.\n${text}`, + }, + ], + stream: true, + }); + let raw = ""; + for await (const token of result.tokenStream) { + raw += token; + } + return raw; + }, + async translate(text: string, from: string, to: string) { + const result = translate({ + modelId, + text, + from, + to, + modelType: "llm", + stream: false, + }); + return await result.text; + }, + }; + + return { + client, + dispose: async () => { + await unloadModel({ modelId, clearStorage: false }); + }, + }; +} diff --git a/packages/adapters-qvac/src/qvacRuntime.ts b/packages/adapters-qvac/src/qvacRuntime.ts new file mode 100644 index 0000000..6ba7f71 --- /dev/null +++ b/packages/adapters-qvac/src/qvacRuntime.ts @@ -0,0 +1,2 @@ +export * from "./createQvacCompletionClient.js"; +export * from "./createQvacTranslationClient.js"; diff --git a/packages/core/src/application/use-cases/AnalyzeSendIntent.ts b/packages/core/src/application/use-cases/AnalyzeSendIntent.ts index eb2632e..b3e1466 100644 --- a/packages/core/src/application/use-cases/AnalyzeSendIntent.ts +++ b/packages/core/src/application/use-cases/AnalyzeSendIntent.ts @@ -14,6 +14,14 @@ export interface AnalyzeSendIntentDeps { readonly newId?: () => string; } +export interface AnalyzeSendIntentOptions { + /** + * When TranslateAndAnalyzeMessage already ran detection on (translated) + * text, pass those matches so the model is not invoked a second time. + */ + readonly textMatches?: readonly ScamMatch[]; +} + /** * The central use case: given what the user is about to send, and to whom, * decide how much friction to introduce before WDK is allowed to sign. @@ -23,12 +31,16 @@ export interface AnalyzeSendIntentDeps { export class AnalyzeSendIntent { constructor(private readonly deps: AnalyzeSendIntentDeps) {} - async execute(intent: SendIntent): Promise { + async execute(intent: SendIntent, options: AnalyzeSendIntentOptions = {}): Promise { const now = this.deps.now?.() ?? new Date(); const newId = this.deps.newId ?? (() => crypto.randomUUID()); const [textMatches, addressReputation] = await Promise.all([ - intent.context ? this.deps.scamDetection.analyzeText(intent.context.text) : Promise.resolve([]), + options.textMatches + ? Promise.resolve(options.textMatches) + : intent.context + ? this.deps.scamDetection.analyzeText(intent.context.text) + : Promise.resolve([]), this.deps.riskList.lookup(intent.destination), ]); diff --git a/packages/core/src/application/use-cases/__tests__/AnalyzeSendIntent.test.ts b/packages/core/src/application/use-cases/__tests__/AnalyzeSendIntent.test.ts index 7a5455f..3462418 100644 --- a/packages/core/src/application/use-cases/__tests__/AnalyzeSendIntent.test.ts +++ b/packages/core/src/application/use-cases/__tests__/AnalyzeSendIntent.test.ts @@ -65,6 +65,33 @@ describe("AnalyzeSendIntent", () => { expect(result.matches).toHaveLength(1); }); + it("uses precomputed text matches and does not call scamDetection again", async () => { + const pattern = { + id: "p1", + category: "pig-butchering" as const, + description: "test", + heuristics: [], + }; + const scamDetection: ScamDetectionPort = { + analyzeText: async () => { + throw new Error("analyzeText should not run when textMatches are provided"); + }, + }; + const riskList: RiskListPort = { + lookup: async () => ({ address, flagged: false, source: "none" }), + reportScam: async () => {}, + sync: async () => {}, + }; + const useCase = new AnalyzeSendIntent({ scamDetection, riskList }); + + const result = await useCase.execute(makeIntent({ context: { text: "already translated" } }), { + textMatches: [{ pattern, confidence: 0.7 }], + }); + + expect(result.level).toBe(RiskLevel.High); + expect(result.matches).toHaveLength(1); + }); + it("flags a lookalike destination address as a poisoning attempt", async () => { const scamDetection: ScamDetectionPort = { analyzeText: async () => [] }; const riskList: RiskListPort = { diff --git a/packages/core/src/application/use-cases/__tests__/TranslateAndAnalyzeMessage.test.ts b/packages/core/src/application/use-cases/__tests__/TranslateAndAnalyzeMessage.test.ts new file mode 100644 index 0000000..52a9fdf --- /dev/null +++ b/packages/core/src/application/use-cases/__tests__/TranslateAndAnalyzeMessage.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { TranslateAndAnalyzeMessage } from "../TranslateAndAnalyzeMessage.js"; +import { languageTag } from "../../../domain/value-objects/Language.js"; +import type { TranslationPort } from "../../../domain/ports/TranslationPort.js"; +import type { ScamDetectionPort } from "../../../domain/ports/ScamDetectionPort.js"; + +const en = languageTag("en"); +const es = languageTag("es"); + +describe("TranslateAndAnalyzeMessage", () => { + it("skips translation and analyzes the original text when languages match", async () => { + const analyzed: string[] = []; + const translation: TranslationPort = { + detectLanguage: async () => en, + translate: async () => { + throw new Error("translate should not run"); + }, + }; + const scamDetection: ScamDetectionPort = { + analyzeText: async (text) => { + analyzed.push(text); + return []; + }, + }; + + const result = await new TranslateAndAnalyzeMessage({ translation, scamDetection }).execute( + "hello", + en, + ); + + expect(analyzed).toEqual(["hello"]); + expect(result.message).toEqual({ text: "hello", detectedLanguage: en }); + }); + + it("translates first, then analyzes the translated text", async () => { + const analyzed: string[] = []; + const translation: TranslationPort = { + detectLanguage: async () => es, + translate: async (text, target) => ({ + text: "guaranteed 30% weekly returns", + detectedLanguage: target, + originalText: text, + originalLanguage: es, + targetLanguage: target, + }), + }; + const scamDetection: ScamDetectionPort = { + analyzeText: async (text) => { + analyzed.push(text); + return []; + }, + }; + + const result = await new TranslateAndAnalyzeMessage({ translation, scamDetection }).execute( + "rendimientos semanales garantizados", + en, + ); + + expect(analyzed).toEqual(["guaranteed 30% weekly returns"]); + expect("originalText" in result.message).toBe(true); + }); +});