Spaces:
Sleeping
Sleeping
File size: 14,823 Bytes
6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab 6626cb3 b9eddab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 | # ============================================================================
# workbench_codes.py β Sentence-level coding (Layer 1)
# ============================================================================
"""Layer 1 coder β assigns one short, POS-bound code to every sentence.
Purpose
-------
Implement the Braun & Clarke 2006 Phase 2 ("Generating initial codes") /
Grounded Theory open-coding step in code. Every sentence in the corpus
receives a 2-3 word noun-phrase code following the same POS-rule grammar
as the cluster-level theme labels (Layer 2). Same rule at both layers =
researcher-readable, repeatable, and inter-rater consistent.
The grammar
-----------
Every code MUST be:
- 2 OR 3 words (never 1, never 4+)
- [ADJ] + N or N + N pattern
- Title Case (every word capitalised)
- Singular noun head (unless the concept is inherently plural)
- No verb as head, no pronoun, no article ("a", "an", "the")
- No quotes, no punctuation, no preamble
Inputs
------
sentences : list[str]
llm_provider : str (workbench provider key)
llm_key : str (API key)
batch_size : int β sentences per LLM round-trip (default 20)
Outputs
-------
list[str] codes, one per input sentence (parallel order).
Side effects
------------
None. Pure function except for the LLM HTTP call.
Contract
--------
code_sentences(sentences, llm_provider, llm_key, batch_size) -> list[str]
"""
from __future__ import annotations
import re
from typing import Iterable
import providers
# ----------------------------------------------------------------
# The codified rule β sent verbatim to the LLM
# ----------------------------------------------------------------
RULE_PROMPT_HEADER = (
"You are coding sentences for a thematic-analysis codebook (Braun & Clarke "
"2006 Phase 2; SaldaΓ±a 2016, Ch. 3).\n"
"For each sentence below, produce TWO candidate codes (option 01 and "
"option 02) so the researcher can pick the better one (closed card-sort "
"methodology, Krippendorff 2018 Β§7).\n"
"\n"
"RULES every code must follow:\n"
" RULE 1 β Format: ADJECTIVE + NOUN or NOUN + NOUN, 2 OR 3 words total. "
"Never 1, never 4+.\n"
" RULE 2 β Title Case. Every word capitalised.\n"
" RULE 3 β Singular noun head (unless inherently plural).\n"
" RULE 4 β No verbs as head, no pronouns, no articles, no quotes, no "
"punctuation.\n"
" RULE 5 β Use ONLY Nouns and Adjectives.\n"
" RULE 6 β Option 01 and Option 02 MUST use DIFFERENT POS patterns (e.g. "
"01 = N+N, 02 = A+N) so the researcher gets a real choice. If only one "
"valid pattern fits the sentence, change the noun-head wording for option 02.\n"
"\n"
"POS letters: N = Noun, A = Adjective. Valid patterns: N+N, A+N, N+N+N, "
"A+N+N, A+A+N, N+A+N. Forbidden: V (verb), D (determiner), P (pronoun), "
"R (adverb), C (conjunction).\n"
"\n"
"OUTPUT FORMAT β one line per input sentence, in order:\n"
" <option_01_code> | <option_01_pattern> || <option_02_code> | <option_02_pattern>\n"
"Separator between options is the double-pipe '||'. No numbering, no "
"bullets, no preamble, no commentary.\n"
"\n"
"EXAMPLES of valid output lines:\n"
" Stolen Card Purchase | A+N+N || Fraudulent Purchase | A+N\n"
" Defective Product | A+N || Product Defect | N+N\n"
" Poor Hotel Condition | A+N+N || Smoke Smell | N+N\n"
" Bank Fraud | N+N || Fraudulent Bank | A+N\n"
" Refund Delay | N+N || Late Refund | A+N\n"
"\n"
"Sentences to code (one '01 || 02' line per sentence, in order):\n"
)
# Pronouns and articles we reject during post-processing.
_PRONOUNS: frozenset[str] = frozenset({
"i", "me", "my", "mine", "myself",
"we", "us", "our", "ours", "ourselves",
"you", "your", "yours", "yourself", "yourselves",
"he", "him", "his", "himself",
"she", "her", "hers", "herself",
"it", "its", "itself",
"they", "them", "their", "theirs", "themselves",
"this", "that", "these", "those",
})
_ARTICLES: frozenset[str] = frozenset({"a", "an", "the"})
# A simple "looks like a verb" filter for the head word. spaCy would be
# more accurate; this lightweight heuristic catches the common offenders
# without an extra dependency. The full spaCy validator is a follow-up.
_VERB_SUFFIXES: tuple[str, ...] = ("ing",)
_KNOWN_VERB_HEADS: frozenset[str] = frozenset({
"stealing", "buying", "selling", "running", "working", "making",
"breaking", "hiding", "creating", "issuing", "forging",
})
# ----------------------------------------------------------------
# Validator β applies the POS rule programmatically
# ----------------------------------------------------------------
_VALID_POS_LETTERS: frozenset[str] = frozenset({"N", "A"})
_VALID_POS_PATTERNS: frozenset[str] = frozenset({
"N+N", "A+N", "N+N+N", "A+N+N", "A+A+N", "N+A+N",
})
def _split_code_and_pattern(raw: str) -> tuple[str, str]:
"""Split an LLM output line into ``(code, pattern)``.
Accepts ``Bank Fraud | N+N`` or just ``Bank Fraud`` (no pattern). When
the pattern is missing we return an empty string for it; the validator
will then reject the line.
"""
s = (raw or "").strip()
if "|" in s:
left, right = s.split("|", 1)
return left.strip(), right.strip().upper()
return s, ""
def _validate_code(raw: str) -> tuple[bool, str]:
"""Validate just the code text (used for researcher overrides).
Args:
raw: A code string (may include preambles or punctuation).
Returns:
``(True, cleaned_code)`` or ``(False, reason)``.
"""
s = (raw or "").strip()
# Strip common preambles the LLM or researcher occasionally adds.
for prefix in ("Code:", "Theme:", "Label:", "-", "*"):
if s.lower().startswith(prefix.lower()):
s = s[len(prefix):].strip()
s = s.strip("\"'`.,;:!?()[]{}")
if not s:
return False, "empty"
words = s.split()
if len(words) not in (2, 3):
return False, f"wrong word count ({len(words)})"
if any(w.lower() in _PRONOUNS for w in words):
return False, "contains pronoun"
if any(w.lower() in _ARTICLES for w in words):
return False, "contains article"
head = words[-1].lower()
if head.endswith(_VERB_SUFFIXES) or head in _KNOWN_VERB_HEADS:
return False, "verb head (-ing or known verb)"
cleaned = " ".join(w[:1].upper() + w[1:].lower() if w else w for w in words)
return True, cleaned
def _validate_pattern(pattern: str, n_words: int) -> tuple[bool, str]:
"""Validate the LLM's POS pattern (e.g. "A+N", "N+N+N").
Rules:
- non-empty
- made up of '+' separated 1-letter tokens, each in {N, A}
- token count must equal the code's word count
- the joined pattern must be in the closed list of allowed patterns
Returns:
``(True, normalised_pattern)`` or ``(False, reason)``.
"""
if not pattern:
return False, "missing pattern"
tokens = [t.strip().upper() for t in pattern.split("+") if t.strip()]
if not tokens:
return False, "empty pattern"
if len(tokens) != n_words:
return False, f"pattern length {len(tokens)} β code word count {n_words}"
if any(t not in _VALID_POS_LETTERS for t in tokens):
return False, f"contains forbidden POS letter (only N and A allowed): {pattern}"
joined = "+".join(tokens)
if joined not in _VALID_POS_PATTERNS:
return False, f"pattern {joined!r} not in allowed set"
return True, joined
def _validate_line(raw: str) -> tuple[bool, str, str]:
"""Validate a full ``<code> | <pattern>`` line from the LLM.
Args:
raw: One line of LLM output.
Returns:
``(ok, code_or_reason, pattern_or_reason)``.
"""
code_raw, pattern_raw = _split_code_and_pattern(raw)
ok_code, code_value = _validate_code(code_raw)
if not ok_code:
return False, code_value, ""
n_words = len(code_value.split())
ok_pat, pat_value = _validate_pattern(pattern_raw, n_words)
if not ok_pat:
return False, code_value, pat_value
return True, code_value, pat_value
def _split_two_candidates(raw: str) -> tuple[str, str]:
"""Split a single LLM output line into the two candidates.
The expected separator is a double-pipe '||'. Returns
``(option_01_raw, option_02_raw)``. If the LLM didn't produce a
second candidate (or used a different separator), the second
return value is empty so the caller's validator marks option 02
as missing and we fall back to option 01 only.
"""
s = (raw or "").strip()
if "||" in s:
a, b = s.split("||", 1)
return a.strip(), b.strip()
return s, ""
def _validate_two_candidates(raw: str) -> dict:
"""Validate one LLM line that contains two candidate codes.
Returns a dict with the parsed and validated fields:
option_01_code, option_01_pattern,
option_02_code, option_02_pattern,
ok_01, ok_02 (booleans)
Failed candidates collapse to ``("Unlabeled Code", "")``.
"""
raw_01, raw_02 = _split_two_candidates(raw)
ok_01, code_01, pat_01 = _validate_line(raw_01)
ok_02, code_02, pat_02 = _validate_line(raw_02) if raw_02 else (False, "", "")
if not ok_01:
code_01, pat_01 = "Unlabeled Code", ""
if not ok_02:
code_02, pat_02 = "Unlabeled Code", ""
return {
"option_01_code": code_01,
"option_01_pattern": pat_01,
"option_02_code": code_02,
"option_02_pattern": pat_02,
"ok_01": ok_01,
"ok_02": ok_02,
}
# ----------------------------------------------------------------
# Batch coder β the heavy LLM loop
# ----------------------------------------------------------------
def _chunked(seq: list[str], n: int) -> Iterable[list[str]]:
"""Yield successive chunks of ``seq`` of size ``n``."""
for i in range(0, len(seq), n):
yield seq[i:i + n]
def _call_llm_batch(
batch: list[str], llm_provider: str, llm_key: str,
) -> list[str]:
"""Send one batch of sentences and parse the LLM's per-line response.
Args:
batch: The sentences in this batch (1..N strings).
llm_provider: Workbench provider key.
llm_key: API key for the provider.
Returns:
A list of raw code strings, one per input sentence. If the LLM
returns fewer or more lines than expected, the list is padded
with empty strings (which the validator will then reject).
Raises:
RuntimeError: when the provider client raises.
"""
client = providers.get_llm_client(llm_provider or "Mistral", llm_key or "")
model = providers.get_llm_model(llm_provider or "Mistral")
prompt = RULE_PROMPT_HEADER + "\n".join(
f"{i+1}. {s}" for i, s in enumerate(batch)
)
try:
resp = client.chat.complete(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
# Generous: each code ~3 words ~6 tokens Γ 20 sentences = 120 tokens.
max_tokens=64 * len(batch),
)
except Exception as exc:
raise RuntimeError(f"Layer 1 LLM call failed: {exc}") from exc
text = (resp.choices[0].message.content or "").strip()
# Parse "1. Foo Bar" / "Foo Bar" / "- Foo Bar" β strip leading numbering.
lines = [
re.sub(r"^\s*(?:\d+[.)]|-|\*)\s*", "", ln).strip()
for ln in text.splitlines() if ln.strip()
]
# Pad / truncate to len(batch) so caller can rely on parallel indexing.
if len(lines) < len(batch):
lines = lines + [""] * (len(batch) - len(lines))
return lines[:len(batch)]
# ----------------------------------------------------------------
# PUBLIC: code_sentences
# ----------------------------------------------------------------
def code_sentences(
sentences: list[str],
llm_provider: str = "Mistral",
llm_key: str = "",
batch_size: int = 20,
) -> list[dict]:
"""Assign TWO candidate POS-bound codes to every sentence.
Implements forced-choice closed card-sort coding (Krippendorff 2018
Β§7) β the LLM produces two candidates with different POS patterns,
the researcher picks one (default option 01) or overrides with their
own (agreement = N).
Args:
sentences: The corpus.
llm_provider: Workbench provider key. Default Mistral.
llm_key: API key for the provider.
batch_size: Sentences per LLM round-trip. Default 20.
Returns:
A list of dicts parallel to ``sentences``, each containing:
option_01_code, option_01_pattern,
option_02_code, option_02_pattern,
ok_01, ok_02
When a candidate fails POS validation it collapses to
``"Unlabeled Code"`` with empty pattern, and ``ok_NN`` is False
so the UI can flag it.
Raises:
RuntimeError: bubbled up from the LLM call.
Example:
>>> code_sentences(["He used a stolen credit card."])[0]
{'option_01_code': 'Stolen Card Purchase', 'option_01_pattern': 'A+N+N',
'option_02_code': 'Fraudulent Purchase', 'option_02_pattern': 'A+N',
'ok_01': True, 'ok_02': True}
"""
if not sentences:
return []
out: list[dict] = []
for batch in _chunked(list(sentences), max(1, int(batch_size))):
raw_lines = _call_llm_batch(batch, llm_provider, llm_key)
for raw in raw_lines:
out.append(_validate_two_candidates(raw))
return out
def resolve_final_code(
option_01_code: str,
option_02_code: str,
researcher_choice: str,
researcher_code_override: str = "",
) -> tuple[str, str]:
"""Resolve the final_code given the researcher's choice.
Args:
option_01_code: First LLM candidate.
option_02_code: Second LLM candidate.
researcher_choice: "01", "02", or "override".
researcher_code_override: Researcher's typed override (used only
when researcher_choice == "override").
Returns:
``(final_code, researcher_agreement)`` where agreement is "Y"
when choice β {01, 02} and "N" when choice == "override".
Override codes are POS-validated; a failed override becomes
"Unlabeled Code".
"""
choice = (researcher_choice or "01").strip().lower()
if choice == "02":
return option_02_code, "Y"
if choice in ("override", "n"):
if not researcher_code_override:
return option_01_code, "Y"
ok, value = _validate_code(researcher_code_override)
return (value if ok else "Unlabeled Code"), "N"
# default
return option_01_code, "Y"
|