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
8 changes: 7 additions & 1 deletion src/analyze/analyzers/frustration-lexicon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,19 @@ export const FRUSTRATION_LEXICON_VERSION: AnalyzerVersion = {
major: 1,
// 1.1: the prompt now also covers two-word phrases (issue #40), judged as a
// unit rather than as their parts.
// 1.3: phrase precision. At corpus scale, phrases were 84% of adjudications and
// 75% of all hits, and were overwhelmingly redundant β€” `do not`, `is not`,
// `with no`, `πŸ‘ on` β€” each an ordinary word beside one that already signals on
// its own. The prompt now demands a phrase be a fixed expression carrying
// something its parts do not, and says outright that almost every adjacent pair
// is neutral. Paired with a deterministic guard in turn-frustration.
// 1.2: precision. Measured against a real corpus, cheap models flagged `ci`,
// `pr`, `gh`, `sh` and πŸ”€ as frustration β€” 10.7% of vocabulary called
// non-neutral against a 3.8% reference. Two unrelated cheap models failing the
// same way pointed at the prompt, not the model: it never said that naming a
// tool or reporting a status is not a feeling. Existing verdicts stay valid and
// are re-judged only by an explicit `--revise minor`.
minor: 2,
minor: 3,
implementationKind: "in_process_llm",
codeRef: "src/analyze/analyzers/frustration-lexicon/index.ts",
};
Expand Down
21 changes: 16 additions & 5 deletions src/analyze/analyzers/frustration-lexicon/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,22 @@ short two-word phrase, in any language. Judge how that entry is *habitually* use
when a person addresses a software assistant. Do not guess at a specific
conversation; there is none.

For a two-word phrase, judge the phrase as a unit. Many frustration expressions
are exactly this: the individual words are ordinary, and only together do they
express disengagement or annoyance β€” "laisse tomber", "never mind", "forget it",
"trop lent", "come on", "not again". Judge such a phrase on what it means as a
whole, not on its parts.
For a two-word phrase there is a second, harder test: the phrase must be a FIXED
EXPRESSION whose meaning is not simply the sum of its parts, AND it must carry
something its individual words do not already carry.

"laisse tomber", "never mind", "forget it", "trop lent", "come on", "not again"
all pass: each is an idiom, and none of their component words means anything much
alone.

These all FAIL, and they are the common case: "do not", "is not", "with no",
"context no", "and stop", "it still", "πŸ‘ on". Each is just an ordinary word
sitting next to a word that is already a signal on its own. Adding the pair says
nothing new, so the pair is "neutral". If you would flag the phrase only because
one of its words is negative or emphatic, the answer is "neutral".

Adjacent words in running prose are overwhelmingly NOT idioms. Expect to answer
"neutral" for almost every phrase you are given.

Return your judgement by calling the \`classify_term\` tool with exactly these fields:
{
Expand Down
31 changes: 23 additions & 8 deletions src/analyze/analyzers/turn-frustration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export const TURN_FRUSTRATION_DEF: AnalyzerDef = {
export const TURN_FRUSTRATION_VERSION: AnalyzerVersion = {
analyzerId: TURN_FRUSTRATION_DEF.id,
major: 1,
// 1.1: also match learned two-word phrases (issue #40). Purely additive β€” phrase
// hits are new (turn, signal) subjects, so every existing hit node keeps its
// identity and nothing is recomputed.
minor: 1,
// 1.1: also match learned two-word phrases (issue #40).
// 1.2: a phrase hit is suppressed when one of its component words already fired
// on the same turn. Measured over a real corpus, phrases produced 28,179 hits
// against the word lexicon's 9,350, nearly all of them restating a word that had
// already signalled. Fewer hits by design.
minor: 2,
implementationKind: "deterministic",
codeRef: "src/analyze/analyzers/turn-frustration/index.ts",
};
Expand Down Expand Up @@ -169,12 +171,25 @@ export const turnFrustrationAnalyzer: Analyzer = {
);
}

// Phrase hits. A phrase and its component words are separate subjects with
// separate verdicts, so both may fire on the same turn β€” each is a real,
// independently-judged signal and each gets its own node, which is what keeps
// growth additive.
// Phrase hits, but only where the phrase contributes something its component
// words do not already contribute by themselves.
//
// Without this rule the feature drowns in redundancy. Over a real corpus it
// produced 28,179 phrase hits against the word lexicon's 9,350, and the top
// entries were all restatements of a word that had already fired: `do not`
// Γ—563, `is not` Γ—475, `with no` Γ—247 β€” and most plainly `πŸ‘ on` Γ—285 and
// `with πŸ‘` Γ—285, which are a praise emoji plus whichever word happened to
// sit beside it.
//
// The test is contribution, not novelty of spelling: if a component word is
// itself a signal that fired on this same turn, the phrase says nothing new
// and is dropped. That is exactly what preserves `laisse tomber` β€” both
// parts neutral, the meaning living only in the pair β€” while discarding
// `do not`. Deterministic, so it holds whichever model judged the phrase.
const firedWords = new Set(counts.keys());
const knownPhrases = new Set([...lexicon.keys()].filter((k) => k.includes(" ")));
for (const { phrase, count } of matchPhrases(pair.userText, knownPhrases)) {
if (phrase.split(" ").some((word) => firedWords.has(word))) continue;
const entry = lexicon.get(phrase)!;
units.push(
hitUnit(pair.index, pair.userMessageId, {
Expand Down
115 changes: 115 additions & 0 deletions tests/component/phrase-redundancy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* A phrase must earn its hit.
*
* Run over a real corpus, the phrase feature produced 28,179 hits against the
* word lexicon's 9,350 β€” and the top phrases were all redundant with a word that
* had already fired: `do not` Γ—563, `is not` Γ—475, `with no` Γ—247, and most
* tellingly `πŸ‘ on` Γ—285 and `with πŸ‘` Γ—285, where πŸ‘ is itself a praise term and
* the "phrase" is just that emoji plus whatever word sat beside it.
*
* The rule that separates signal from noise is *contribution*: a phrase counts
* only when it says something its component words do not already say. That is
* exactly what makes `laisse tomber` worth having β€” neither `laisse` nor
* `tomber` carries it β€” and exactly what makes `do not` worthless once `not` is
* in the lexicon.
*
* Enforced deterministically at match time rather than left to the model, so it
* holds regardless of which model judged the phrase.
*/

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { tempDb, insertSession, insertMessages } from "./helpers.js";
import { AnalyzerFramework } from "../../src/analyze/framework.js";
import { createMockLLM } from "../../src/analyze/mock-llm.js";
import { DEFAULT_MODEL_TIERS } from "../../src/analyze/model-tiers.js";
import { turnPairCoreAnalyzer } from "../../src/analyze/analyzers/turn-pair-core/index.js";
import { lexiconCandidatesAnalyzer } from "../../src/analyze/analyzers/lexicon-candidates/index.js";
import { frustrationLexiconAnalyzer } from "../../src/analyze/analyzers/frustration-lexicon/index.js";
import { turnFrustrationAnalyzer, TURN_FRUSTRATION_DEF, type TurnFrustrationProperties } from "../../src/analyze/analyzers/turn-frustration/index.js";
import { getNodesByAnalyzer } from "../../src/db/analysis-queries.js";
import type { LLMRequest } from "../../src/analyze/types.js";

/**
* A stub lexicon. `not` and πŸ‘ are word-level signals; `laisse tomber` is an
* idiom whose parts are neutral β€” the shape that must survive.
*/
const FLAGGED: Record<string, "frustration" | "praise"> = {
not: "frustration",
"πŸ‘": "praise",
"laisse tomber": "frustration",
"do not": "frustration",
"πŸ‘ on": "praise",
"trop lent": "frustration",
};

function build(db: Parameters<typeof getNodesByAnalyzer>[0]) {
const llm = createMockLLM({
responder: (req: LLMRequest) => {
const term = String((req.user.match(/TERM:\s*(.*)/) ?? [])[1] ?? "").trim();
const pol = FLAGGED[term];
return {
text: "x",
structured: {
polarity: pol ?? "neutral",
category: pol ? (pol === "praise" ? "praise" : "negation") : "none",
language: "und",
confidence: 0.9,
rationale: "r",
},
};
},
});
const framework = new AnalyzerFramework({ db, llm: llm.caller, modelTiers: DEFAULT_MODEL_TIERS });
for (const a of [turnPairCoreAnalyzer, lexiconCandidatesAnalyzer, frustrationLexiconAnalyzer, turnFrustrationAnalyzer]) {
framework.register(a);
}
return { framework, llm };
}

async function hitsFor(text: string): Promise<TurnFrustrationProperties[]> {
const { db, close } = tempDb();
try {
insertSession(db, "s1");
insertMessages(db, "s1", [{ role: "user", text }, { role: "assistant", text: "ok" }]);
const { framework } = build(db);
await framework.run("s1");
return getNodesByAnalyzer(db, TURN_FRUSTRATION_DEF.id, "s1").map(
(n) => JSON.parse(n.content_json) as TurnFrustrationProperties,
);
} finally {
close();
}
}

describe("phrases must contribute beyond their parts", () => {
it("keeps an idiom whose component words are neutral", async () => {
const signals = (await hitsFor("bon, laisse tomber")).map((h) => h.signal);
assert.deepEqual(signals, ["laisse tomber"], "the motivating case must still fire");
});

it("drops a phrase that merely repeats a word already firing", async () => {
const hits = await hitsFor("do not use that");
assert.deepEqual(
hits.map((h) => h.signal),
["not"],
"`do not` adds nothing once `not` has fired β€” one hit, not two",
);
});

it("drops a phrase built from an emoji that already fired", async () => {
// The real corpus case: `πŸ‘ on` Γ—285 and `with πŸ‘` Γ—285, both redundant.
const hits = await hitsFor("πŸ‘ on the fix");
assert.deepEqual(hits.map((h) => h.signal), ["πŸ‘"]);
});

it("keeps an idiom and a genuinely separate word signal in the same turn", async () => {
const signals = (await hitsFor("not this. laisse tomber")).map((h) => h.signal).sort();
assert.deepEqual(signals, ["laisse tomber", "not"], "both carry distinct signal");
});

it("keeps two independent idioms", async () => {
const signals = (await hitsFor("trop lent. laisse tomber")).map((h) => h.signal).sort();
assert.deepEqual(signals, ["laisse tomber", "trop lent"]);
});
});