Skip to content
Closed
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
47 changes: 39 additions & 8 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<RiskAssessment | null>(null);
const [translated, setTranslated] = useState<TranslatedChatMessage | null>(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);
Expand Down Expand Up @@ -72,6 +98,11 @@ export function App() {
}}
>
<strong>Risk level: {assessment.level}</strong>
{translated && (
<p>
Translated on-device from {translated.originalLanguage} to {translated.targetLanguage}.
</p>
)}
<p>{assessment.summary}</p>
{requiresHardBlock(assessment.level) && <p>This send would be blocked — WDK signing must not proceed.</p>}
</div>
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/compositionRoot.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
2 changes: 1 addition & 1 deletion docs/performance-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters-qvac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters-qvac/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
55 changes: 41 additions & 14 deletions packages/adapters-qvac/src/QvacTranslateAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
translate(text: string, from: string, to: string): Promise<string>;
}

/**
* 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<LanguageTag> {
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<LanguageTag> {
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<TranslatedChatMessage> {
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");
35 changes: 35 additions & 0 deletions packages/adapters-qvac/src/__tests__/QvacTranslateAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions packages/adapters-qvac/src/createQvacTranslationClient.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}> {
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 });
},
};
}
2 changes: 2 additions & 0 deletions packages/adapters-qvac/src/qvacRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./createQvacCompletionClient.js";
export * from "./createQvacTranslationClient.js";
16 changes: 14 additions & 2 deletions packages/core/src/application/use-cases/AnalyzeSendIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,12 +31,16 @@ export interface AnalyzeSendIntentDeps {
export class AnalyzeSendIntent {
constructor(private readonly deps: AnalyzeSendIntentDeps) {}

async execute(intent: SendIntent): Promise<RiskAssessment> {
async execute(intent: SendIntent, options: AnalyzeSendIntentOptions = {}): Promise<RiskAssessment> {
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<readonly ScamMatch[]>([]),
options.textMatches
? Promise.resolve(options.textMatches)
: intent.context
? this.deps.scamDetection.analyzeText(intent.context.text)
: Promise.resolve<readonly ScamMatch[]>([]),
this.deps.riskList.lookup(intent.destination),
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading