SmartPaper / core /qualitative_engine.py
GuXiaopeng's picture
A5-R3-R4C dormant ModelPolicy v2 execution seam
7ae3284 verified
Raw History Blame
51.1 kB
# -*- coding: utf-8 -*-
"""Qualitative evidence preparation for SmartPaper's ASQ-oriented track."""
from __future__ import annotations
import hashlib
import json
import logging
import re
from config import APP_CONFIG
class QualitativeEngine:
QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v2"
SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
APPENDIX_CANONICAL_LABEL_FIELDS = ("theme", "concept")
APPENDIX_HEADERS = (
"Higher-order Theme", "Empirical Concept", "Process Stage",
"Case/Actor", "Evidence Role", "Representative Quote",
)
_TARGET_APPENDIX_LOCALIZATION = {
"en": {
"heading": "Appendix: Data Structure and Evidence",
"headers": APPENDIX_HEADERS,
},
"zh": {
"heading": "附录:数据结构与证据",
"headers": ("高阶主题", "经验概念", "过程阶段", "案例/参与者", "证据角色", "代表性引文"),
},
"ru": {
"heading": "Приложение: Структура данных и доказательства",
"headers": ("Тема высшего порядка", "Эмпирическое понятие", "Этап процесса", "Случай/участник", "Роль доказательства", "Репрезентативная цитата"),
},
"kk": {
"heading": "Қосымша: Деректер құрылымы және дәлелдер",
"headers": ("Жоғары деңгейлі тақырып", "Эмпирикалық ұғым", "Процесс кезеңі", "Кейс/қатысушы", "Дәлел рөлі", "Репрезентативті дәйексөз"),
},
}
ALLOWED_ANALYTIC_DESIGNS = {
"Process analysis",
"Comparative case analysis",
"Grounded theory",
"Gioia-style inductive coding",
"Longitudinal qualitative analysis",
"Iterative thematic/process analysis",
}
def __init__(self, agent):
self.agent = agent
@staticmethod
def _clean_cell(value, default="Not specified"):
cleaned = str(value or default).replace("\n", " ").strip()
return cleaned or default
@classmethod
def _normalize_analytic_design(cls, value):
cleaned = re.sub(r"\s+", " ", str(value or "")).strip()
if not cleaned:
return "Iterative thematic/process analysis"
lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
@classmethod
def _normalize_string_list(cls, values):
if values is None:
return []
if not isinstance(values, list):
values = [values]
out = []
for value in values:
cleaned = cls._clean_cell(value, "")
if cleaned and cleaned.casefold() != "not specified" and cleaned not in out:
out.append(cleaned)
return out
@classmethod
def _normalize_data_source_roles(cls, values):
rows = []
for item in values or []:
if not isinstance(item, dict):
continue
actor_role = cls._clean_cell(item.get("actor_role"), "")
data_source = cls._clean_cell(item.get("data_source"), "")
count = item.get("count")
if not actor_role or not data_source:
continue
if count is not None and (not isinstance(count, int) or count <= 0):
continue
row = {"actor_role": actor_role, "data_source": data_source, "count": count}
if row not in rows:
rows.append(row)
return rows
@classmethod
def build_qualitative_semantic_claim_frame(cls, metadata):
"""Freeze actor/source, process-evidence, and quote identities in one frame.
The frame is server-side authority. It contains no model-inferred prose
meaning and no verbatim quotation payload, only typed identities/hashes.
"""
meta = dict(metadata or {})
facts = dict(meta.get("study_facts") or {})
relations = []
for row in cls._normalize_data_source_roles(facts.get("data_source_roles") or []):
actor = cls._clean_cell(row.get("actor_role"), "")
source = cls._clean_cell(row.get("data_source"), "")
folded = source.casefold()
if "interview" in folded:
family = "interview"
elif "focus" in folded and "group" in folded:
family = "focus_group"
elif "observ" in folded:
family = "observation"
elif "log" in folded:
family = "log"
elif "document" in folded or "archiv" in folded or "record" in folded:
family = "document"
else:
family = "other"
relation = {
"actor_role": actor,
"data_source": source,
"source_family": family,
"count": row.get("count"),
"relation": "EVIDENCED_BY",
}
relation_id_payload = json.dumps(relation, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
relation["relation_id"] = hashlib.sha256(relation_id_payload.encode("utf-8")).hexdigest()[:16]
relations.append(relation)
quotes = []
for item in meta.get("evidence") or []:
if not isinstance(item, dict):
continue
quote_id = str(item.get("tag") or "").strip()
if not re.fullmatch(r"QUOTE-\d+", quote_id):
continue
quote_hash = str(item.get("quote_hash") or "").strip()
if not quote_hash and str(item.get("quote") or "").strip():
normalized = re.sub(r"\s+", " ", str(item.get("quote") or "")).strip()
quote_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
quotes.append({
"quote_id": quote_id,
"quote_hash": quote_hash,
"theme_id": str(item.get("theme_id") or "").strip(),
"stage": cls._clean_cell(item.get("stage")),
"case_label": cls._clean_cell(item.get("case_label")),
"evidence_role": str(item.get("evidence_role") or "typical").strip().lower(),
})
process_evidence = []
for theme in meta.get("themes") or []:
if not isinstance(theme, dict):
continue
theme_id = str(theme.get("theme_id") or "").strip()
if not theme_id:
continue
process_evidence.append({
"theme_id": theme_id,
"stages": [cls._clean_cell(x, "") for x in (theme.get("stages") or []) if cls._clean_cell(x, "")],
"has_deviant_evidence": bool(theme.get("has_deviant_evidence")),
"quote_ids": [
str(row.get("tag") or "").strip()
for row in (theme.get("evidence") or []) if isinstance(row, dict) and str(row.get("tag") or "").strip()
],
})
frame = {
"version": cls.SEMANTIC_CLAIM_FRAME_VERSION,
"mode": "qualitative",
"actor_data_source_relations": sorted(relations, key=lambda row: (row["actor_role"].casefold(), row["data_source"].casefold())),
"process_evidence": sorted(process_evidence, key=lambda row: row["theme_id"]),
"quote_identities": sorted(quotes, key=lambda row: row["quote_id"]),
}
canonical = json.dumps(frame, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
frame["frame_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return frame
@classmethod
def build_scoped_evidence_md(
cls,
metadata,
*,
include_themes=True,
theme_titles=None,
assigned_evidence=None,
include_quotes=False,
include_quote_ids=True,
):
"""Render the minimum qualitative evidence needed by one drafting unit.
Study facts/theme summaries are an internal empirical channel. Verbatim
quotations are a separate channel and are exposed only to their owning
Findings unit (or the final appendix when explicitly requested by the
caller). This prevents a quotation visible in one unit from being
opportunistically reused elsewhere and keeps external literature from
becoming evidence for this study's own facts.
"""
metadata = metadata or {}
facts = metadata.get("study_facts") or {}
sources = facts.get("data_sources") or ["Not specified"]
if not isinstance(sources, list):
sources = [sources]
clean_sources = [cls._clean_cell(item) for item in sources]
participants = facts.get("participant_count")
interviews = facts.get("interview_count")
analytic_design = cls._normalize_analytic_design(
facts.get("analytic_design") or facts.get("analysis_method")
)
analytic_logic = cls._clean_cell(
facts.get("analytic_logic"),
"Iteratively compare empirical observations to refine a process/mechanism explanation.",
)
temporal_structure = cls._clean_cell(facts.get("temporal_structure"))
case_structure = cls._clean_cell(facts.get("case_structure"))
setting = cls._clean_cell(facts.get("research_setting"))
data_source_roles = cls._normalize_data_source_roles(facts.get("data_source_roles") or [])
trustworthiness = cls._normalize_string_list(facts.get("trustworthiness_procedures"))
ethics = cls._normalize_string_list(facts.get("ethics_procedures"))
reflexivity = cls._normalize_string_list(facts.get("reflexivity_procedures"))
lines = [
"### Frozen Qualitative Study Facts",
"| Field | Frozen value |",
"| :--- | :--- |",
f"| Research setting | {setting} |",
f"| Data sources | {', '.join(clean_sources)} |",
f"| Participants | {participants if participants is not None else 'Not specified'} |",
f"| Interviews | {interviews if interviews is not None else 'Not specified'} |",
f"| Analytic design | {analytic_design} |",
f"| Analytic logic | {analytic_logic} |",
f"| Temporal structure | {temporal_structure} |",
f"| Case structure | {case_structure} |",
f"| Trustworthiness procedures | {'; '.join(trustworthiness) if trustworthiness else 'Not specified'} |",
f"| Ethics procedures | {'; '.join(ethics) if ethics else 'Not specified'} |",
f"| Reflexivity procedures | {'; '.join(reflexivity) if reflexivity else 'Not specified'} |",
]
if data_source_roles:
lines.extend([
"",
"### Frozen Actor / Data-Source Relations",
"| Actor role | Data source | Count |",
"| :--- | :--- | :--- |",
])
lines.extend(
f"| {row['actor_role']} | {row['data_source']} | {row['count'] if row['count'] is not None else 'Not specified'} |"
for row in data_source_roles
)
lines.extend([
"",
"[QUALITATIVE EVIDENCE CHANNELS]",
"1. STUDY FACTS and THEME SUMMARIES below are this study's internal empirical evidence. Never attach an external [CITE-XX] tag to a sentence merely because it reports or interprets these frozen facts.",
"2. External literature may support only prior-theory, prior-evidence, or methodological-rationale claims. Keep those claims in distinct sentences from this study's setting, sample, data, events, quotations, and findings.",
"3. ASSIGNED QUOTATIONS, when present, are analysis-only verbatim evidence owned by the current stable theme. Do not reproduce the verbatim wording or any internal quotation identifier in generated prose; the Executor renders frozen quotations deterministically after narrative QA.",
"4. Study-owned trustworthiness, ethics, reflexivity, recruitment, consent, observation, or audit procedures may be stated as performed only when explicitly frozen above. Never convert general methodological best practice into a fact about this study.",
])
allowed_theme_titles = None
if theme_titles:
allowed_theme_titles = {
re.sub(r"\s+", " ", str(item or "")).strip().casefold()
for item in theme_titles
if str(item or "").strip()
}
if include_themes:
theme_rows = []
for theme in metadata.get("themes") or []:
if not isinstance(theme, dict):
continue
title = cls._clean_cell(theme.get("title"), "Emergent pattern")
if allowed_theme_titles is not None and title.casefold() not in allowed_theme_titles:
continue
concepts = ", ".join(
cls._clean_cell(item, "")
for item in (theme.get("concepts") or [])
if cls._clean_cell(item, "")
) or "Not specified"
stages = ", ".join(
cls._clean_cell(item, "")
for item in (theme.get("stages") or [])
if cls._clean_cell(item, "") and cls._clean_cell(item, "") != "Not specified"
) or "Not specified"
deviant = "yes" if theme.get("has_deviant_evidence") else "no"
theme_rows.append((title, concepts, stages, deviant))
if theme_rows:
lines.extend([
"",
"### Frozen Qualitative Theme Summary",
"| Higher-order Theme | Empirical Concepts | Process Stages | Deviant/Boundary Evidence Present |",
"| :--- | :--- | :--- | :--- |",
])
lines.extend(
f"| {title} | {concepts} | {stages} | {deviant} |"
for title, concepts, stages, deviant in theme_rows
)
if include_quotes:
evidence_items = assigned_evidence
if evidence_items is None:
evidence_items = metadata.get("evidence") or []
quote_rows = []
for item in evidence_items or []:
if not isinstance(item, dict):
continue
tag = cls._clean_cell(item.get("tag"), "")
quote = cls._clean_cell(item.get("quote"), "").replace('"', "'")
if not tag or not quote:
continue
stage = cls._clean_cell(item.get("stage"))
case_label = cls._clean_cell(item.get("case_label"))
evidence_role = str(item.get("evidence_role") or "typical").strip().lower()
if evidence_role not in {"typical", "deviant"}:
evidence_role = "typical"
quote_rows.append((tag, stage, case_label, evidence_role, quote))
if quote_rows:
lines.extend(["", "### Assigned Qualitative Quotations"])
if include_quote_ids:
lines.extend([
"| Tag | Process Stage | Case/Actor | Evidence Role | Verbatim Quote |",
"| :--- | :--- | :--- | :--- | :--- |",
])
lines.extend(
f'| {tag} | {stage} | {case_label} | {role} | "{quote}" |'
for tag, stage, case_label, role, quote in quote_rows
)
else:
lines.extend([
"| Process Stage | Case/Actor | Evidence Role | Verbatim Quote |",
"| :--- | :--- | :--- | :--- |",
])
lines.extend(
f'| {stage} | {case_label} | {role} | "{quote}" |'
for _tag, stage, case_label, role, quote in quote_rows
)
return "\n".join(lines).strip()
@staticmethod
def _appendix_target_language_code(target_lang):
value = str(target_lang or "").strip().casefold().replace("_", "-")
if value.startswith("zh") or "chinese" in value or "中文" in value or "简体" in value:
return "zh"
if value.startswith("ru") or "russian" in value or "рус" in value:
return "ru"
if value.startswith("kk") or value.startswith("kz") or "kazakh" in value or "қазақ" in value:
return "kk"
return "en"
@classmethod
def build_appendix_manifest(cls, metadata):
"""Freeze Appendix row/cell identity before any target-language operation."""
rows = []
seen = set()
for index, item in enumerate((metadata or {}).get("evidence") or [], start=1):
if not isinstance(item, dict):
continue
quote = cls._clean_cell(item.get("quote"), "")
if not quote:
continue
quote_id = str(item.get("tag") or "").strip().upper()
if not re.fullmatch(r"QUOTE-\d+", quote_id) or quote_id in seen:
return {}
seen.add(quote_id)
row = {
"row_id": quote_id,
"quote_id": quote_id,
"quote_hash": hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest(),
"theme": cls._clean_cell(item.get("theme"), "Emergent pattern"),
"concept": cls._clean_cell(item.get("concept"), "Empirical observation"),
"stage": cls._clean_cell(item.get("stage")),
"case_label": cls._clean_cell(item.get("case_label")),
"evidence_role": cls._clean_cell(
str(item.get("evidence_role") or "typical").strip().lower(), "typical"
),
"quote": quote,
}
rows.append(row)
if not rows:
return {}
manifest = {
"version": cls.QUALITATIVE_APPENDIX_MANIFEST_VERSION,
"columns": list(cls.APPENDIX_HEADERS),
"rows": rows,
}
manifest["manifest_hash"] = cls.appendix_manifest_hash(manifest)
return manifest
@classmethod
def appendix_manifest_hash(cls, manifest):
record = dict(manifest or {})
record.pop("manifest_hash", None)
payload = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@classmethod
def appendix_translation_cells(cls, manifest):
"""Return typed non-quote Appendix cells; IDs stay server-side."""
cells = {}
for row in (manifest or {}).get("rows") or []:
if not isinstance(row, dict):
continue
row_id = str(row.get("row_id") or "").strip()
if not row_id:
continue
for field in cls.APPENDIX_FIELDS:
value = cls._clean_cell(row.get(field), "Not specified")
cells[f"{row_id}:{field}"] = value
return cells
@classmethod
def appendix_translation_plan(cls, manifest, glossary=None):
"""Split Appendix cells into server-bound labels and model-owned surfaces.
Theme/concept cells are frozen research labels. When the task-level
canonical glossary already owns their target identity, the translation
model must not rewrite those labels or their internal anchors. Remaining
ordinary cells (stage/case/evidence-role) continue through the typed
translation compiler and are rebound by server-side cell ID.
"""
expected = cls.appendix_translation_cells(manifest)
glossary_value = dict(glossary or {})
folded_glossary = {
str(key or '').strip().casefold(): str(value or '').strip()
for key, value in glossary_value.items() if str(key or '').strip() and str(value or '').strip()
}
prebound = {}
model_cells = {}
for cell_id, source in expected.items():
field = str(cell_id or '').rsplit(':', 1)[-1]
canonical_target = str(glossary_value.get(source) or '').strip()
if not canonical_target:
canonical_target = folded_glossary.get(str(source or '').strip().casefold(), '')
if field in cls.APPENDIX_CANONICAL_LABEL_FIELDS and canonical_target:
prebound[cell_id] = canonical_target
else:
model_cells[cell_id] = source
if set(prebound) & set(model_cells) or set(prebound) | set(model_cells) != set(expected):
raise RuntimeError("qualitative_appendix_translation_plan_identity_mismatch")
return prebound, model_cells
@staticmethod
def _escape_appendix_markdown_cell(value):
return re.sub(r"\s+", " ", str(value or "").strip()).replace("|", r"\|")
@classmethod
def _validate_appendix_manifest(cls, manifest):
record = dict(manifest or {})
if record.get("version") != cls.QUALITATIVE_APPENDIX_MANIFEST_VERSION:
return False
if list(record.get("columns") or []) != list(cls.APPENDIX_HEADERS):
return False
rows = [row for row in (record.get("rows") or []) if isinstance(row, dict)]
ids = [str(row.get("row_id") or "") for row in rows]
quote_ids = [str(row.get("quote_id") or "") for row in rows]
if not rows or len(ids) != len(set(ids)) or ids != quote_ids:
return False
if any(not re.fullmatch(r"QUOTE-\d+", item) for item in ids):
return False
expected_hash = str(record.get("manifest_hash") or "")
return bool(expected_hash) and expected_hash == cls.appendix_manifest_hash(record)
@classmethod
def bind_target_language_appendix(cls, manifest, target_lang, cell_translations, quote_translations):
"""Render one six-column target Appendix from frozen semantic objects.
Markdown structure, row identity, column identity, and quote identity are
program-owned. The translation model may supply only ordinary cell
surfaces through ``cell_translations``.
"""
if not cls._validate_appendix_manifest(manifest):
raise RuntimeError("target_qualitative_appendix_manifest_invalid")
expected_cells = cls.appendix_translation_cells(manifest)
translations = dict(cell_translations or {})
if set(translations) != set(expected_cells):
missing = sorted(set(expected_cells) - set(translations))
extra = sorted(set(translations) - set(expected_cells))
raise RuntimeError(
f"target_qualitative_appendix_cell_identity_mismatch:missing={missing[:4]} extra={extra[:4]}"
)
quote_map = dict(quote_translations or {})
required_quotes = {str(row.get("quote_id") or "") for row in manifest.get("rows") or []}
missing_quotes = sorted(tag for tag in required_quotes if not str(quote_map.get(tag) or "").strip())
if missing_quotes:
raise RuntimeError(f"target_qualitative_appendix_quote_identity_missing:{missing_quotes[:6]}")
code = cls._appendix_target_language_code(target_lang)
profile = dict(cls._TARGET_APPENDIX_LOCALIZATION.get(code) or cls._TARGET_APPENDIX_LOCALIZATION["en"])
headers = list(profile.get("headers") or cls.APPENDIX_HEADERS)
heading = str(profile.get("heading") or cls._TARGET_APPENDIX_LOCALIZATION["en"]["heading"])
lines = [
"| " + " | ".join(cls._escape_appendix_markdown_cell(item) for item in headers) + " |",
"| :--- | :--- | :--- | :--- | :--- | :--- |",
]
for row in manifest.get("rows") or []:
row_id = str(row.get("row_id") or "")
values = [translations[f"{row_id}:{field}"] for field in cls.APPENDIX_FIELDS]
values.append(quote_map[str(row.get("quote_id") or "")])
lines.append(
"| " + " | ".join(cls._escape_appendix_markdown_cell(value) for value in values) + " |"
)
rendered = f"# {heading}\n\n" + "\n".join(lines)
cls.validate_bound_target_appendix(rendered, manifest, target_lang)
return rendered.strip()
@classmethod
def validate_bound_target_appendix(cls, text, manifest, target_lang):
"""Fail before DOCX packaging if semantic Appendix identity drifted."""
if not cls._validate_appendix_manifest(manifest):
raise RuntimeError("target_qualitative_appendix_manifest_invalid")
code = cls._appendix_target_language_code(target_lang)
profile = dict(cls._TARGET_APPENDIX_LOCALIZATION.get(code) or cls._TARGET_APPENDIX_LOCALIZATION["en"])
heading = str(profile.get("heading") or "")
headers = list(profile.get("headers") or cls.APPENDIX_HEADERS)
value = str(text or "")
heading_pattern = re.compile(rf"(?m)^#\s+{re.escape(heading)}\s*$")
matches = list(heading_pattern.finditer(value))
if len(matches) != 1:
raise RuntimeError("target_qualitative_appendix_heading_identity_missing_or_ambiguous")
tail = value[matches[0].end():]
table_blocks = re.findall(r"(?m)(?:^[ \t]*\|[^\n]+\|[ \t]*$\n?)+", tail)
valid_blocks = []
for block in table_blocks:
lines = [line.strip() for line in block.splitlines() if line.strip().startswith("|")]
if not lines:
continue
if all(len(re.findall(r"(?<!\\)\|", line)) == 7 for line in lines):
valid_blocks.append(lines)
if len(valid_blocks) != 1:
raise RuntimeError("target_qualitative_appendix_semantic_table_identity_missing_or_ambiguous")
lines = valid_blocks[0]
expected_rows = len(manifest.get("rows") or []) + 2
if len(lines) != expected_rows:
raise RuntimeError(
f"target_qualitative_appendix_row_identity_mismatch:expected={expected_rows} got={len(lines)}"
)
expected_header = "| " + " | ".join(cls._escape_appendix_markdown_cell(item) for item in headers) + " |"
if lines[0] != expected_header or not re.fullmatch(
r"\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|\s*:---\s*\|",
lines[1],
):
raise RuntimeError("target_qualitative_appendix_column_identity_mismatch")
if re.search(r"\bQUOTE-\d+\b", "\n".join(lines), flags=re.I):
raise RuntimeError("target_qualitative_appendix_internal_quote_id_leak")
return True
@classmethod
def build_appendix_evidence_md(cls, metadata):
"""Render the reader-facing English Appendix from the same frozen manifest."""
manifest = cls.build_appendix_manifest(metadata)
if not cls._validate_appendix_manifest(manifest):
raise RuntimeError("qualitative_appendix_manifest_invalid")
lines = [
"| " + " | ".join(cls.APPENDIX_HEADERS) + " |",
"| :--- | :--- | :--- | :--- | :--- | :--- |",
]
for row in manifest.get("rows") or []:
values = [row.get(field) for field in cls.APPENDIX_FIELDS] + [f'"{row.get("quote")}"']
lines.append(
"| " + " | ".join(cls._escape_appendix_markdown_cell(value) for value in values) + " |"
)
return "\n".join(lines).strip()
def _build_request(self, topic, keywords, raw_interviews):
schema = """Return ONLY one valid JSON object with this schema:
{
"study_facts": {
"research_setting": "string or Not specified",
"data_sources": ["string"],
"data_source_roles": [{"actor_role":"string", "data_source":"string", "count":integer or null}],
"participant_count": integer or null,
"interview_count": integer or null,
"trustworthiness_procedures": ["explicitly supported/frozen procedure"],
"ethics_procedures": ["explicitly supported/frozen procedure"],
"reflexivity_procedures": ["explicitly supported/frozen procedure"],
"analytic_design": "one allowed design",
"analytic_logic": "one concise sentence",
"temporal_structure": "string or Not specified",
"case_structure": "string or Not specified"
},
"evidence": [
{
"theme": "higher-order pattern/theme",
"concept": "empirical concept",
"quote": "verbatim quotation",
"stage": "temporal/process stage or Not specified",
"case_label": "generic case/actor label or Not specified",
"evidence_role": "typical or deviant"
}
]
}
Allowed analytic designs: Process analysis; Comparative case analysis; Grounded theory; Gioia-style inductive coding; Longitudinal qualitative analysis; Iterative thematic/process analysis.
"""
if raw_interviews and len(raw_interviews) > 100:
return f"""Topic: {topic}
Keywords: {keywords}
Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
Rules:
1. Treat the supplied material as the only source of study facts. Use null, empty arrays, or "Not specified" for any setting, sample, count, date, case, actor/data-source relation, trustworthiness procedure, ethics procedure, reflexivity procedure, or data source not explicitly supported by the material.
2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
3. Choose the analytic design that best fits the actual material. Do NOT default to Gioia. Use Gioia-style coding only if the evidence structure genuinely warrants it.
4. Organize evidence around a central organizational puzzle and, when the material supports it, preserve temporal stages, actor/case contrasts, process transitions, and at least one deviant or boundary observation.
5. Do not manufacture a longitudinal sequence when the material is cross-sectional, and do not manufacture multiple cases when only one is present.
6. Freeze actor-to-data-source relations explicitly in data_source_roles whenever the material supports them (for example extension officers -> interviews; farmers -> focus groups). Never infer that an actor participated in a data source not supported by the material. The study_facts object and quotations will become the frozen single source of truth for the entire manuscript.
{schema}
<QUALITATIVE_MATERIAL>
{raw_interviews[:30000]}
</QUALITATIVE_MATERIAL>
"""
return f"""Topic: {topic}
Keywords: {keywords}
Construct one internally coherent qualitative evidence packet for a complete ASQ-oriented manuscript because no interview material was supplied.
Rules:
1. Choose one generic organizational research setting directly relevant to the topic and keep it fixed. Do not name a real organization, institution, or identifiable person.
2. Freeze one participant count, interview count, data-source set, actor-to-data-source relation map, analytic design, temporal/case structure, and analytic logic. Keep them internally consistent. Every actor/data-source relationship later stated in the paper must be represented in data_source_roles.
3. Produce exactly 12 natural representative quotations distributed across at least three higher-order themes. Quotations must sound like distinct participants rather than repeated template variants.
4. Choose the analytic design that best fits the constructed evidence; do NOT default to Gioia. Use process, comparative-case, grounded-theory, longitudinal, or Gioia-style logic only when the packet supports it.
5. Include at least one clearly labelled deviant/boundary observation so the eventual theory is not built only from confirming examples.
6. The evidence should support a process/mechanism explanation, not merely descriptive topic categories.
7. Freeze concise, internally coherent trustworthiness, ethics, and reflexivity procedures in their typed arrays so later Methods prose has an explicit study-owned source. Do not name a real ethics board, institution, vendor, approval number, or identifiable organization.
8. The study_facts object and evidence packet will become the frozen single source of truth for the entire manuscript.
9. Avoid decorative language, generic AI phrasing, and repetitive quotation patterns.
{schema}
"""
def _qualitative_packet_validation_error(self, data, *, has_user_data):
"""Return a bounded local validation reason, or an empty string when safe.
This pre-freeze validator mirrors the existing fatal packet gates so a
malformed/incomplete model response can be regenerated once before any
study fact or quotation becomes authoritative. It never repairs JSON or
mutates model output.
"""
if not isinstance(data, dict):
return "packet_not_json_object"
facts = data.get("study_facts") or {}
evidence = data.get("evidence") or data.get("quotes") or []
if not isinstance(facts, dict) or not isinstance(evidence, list):
return "invalid_packet_schema"
for key in ("participant_count", "interview_count"):
value = facts.get(key)
if value is not None and (not isinstance(value, int) or value <= 0):
return f"invalid_{key}"
raw_relations = facts.get("data_source_roles") or []
if raw_relations and not isinstance(raw_relations, list):
return "invalid_data_source_roles"
declared_sources = [
re.sub(r"\s+", " ", str(item or "")).strip().casefold()
for item in (facts.get("data_sources") or [])
if str(item or "").strip()
]
for item in raw_relations:
if not isinstance(item, dict) or not str(item.get("actor_role") or "").strip() or not str(item.get("data_source") or "").strip():
return "invalid_data_source_role_item"
count = item.get("count")
if count is not None and (not isinstance(count, int) or count <= 0):
return "invalid_data_source_role_count"
relation_source = re.sub(r"\s+", " ", str(item.get("data_source") or "")).strip().casefold()
if declared_sources and not any(
relation_source == source or relation_source in source or source in relation_source
for source in declared_sources
):
return "data_source_role_not_in_declared_sources"
for key in ("trustworthiness_procedures", "ethics_procedures", "reflexivity_procedures"):
value = facts.get(key) or []
if not isinstance(value, list):
return f"invalid_{key}"
if any(not str(item or "").strip() for item in value):
return f"invalid_{key}"
if not has_user_data:
if not raw_relations:
return "generated_packet_missing_data_source_roles"
for key in ("trustworthiness_procedures", "ethics_procedures", "reflexivity_procedures"):
if not (facts.get(key) or []):
return f"generated_packet_missing_{key}"
seen_quotes = set()
theme_keys = set()
retained = 0
has_deviant = False
for item in evidence[:15]:
if not isinstance(item, dict):
continue
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
continue
seen_quotes.add(normalized_quote)
retained += 1
theme = self._clean_cell(item.get("theme"), "Emergent pattern")
theme_keys.add(re.sub(r"\s+", " ", theme).strip().casefold())
if str(item.get("evidence_role") or "typical").strip().lower() == "deviant":
has_deviant = True
validation = APP_CONFIG.get("manuscript_validation", {})
minimum_themes = int(validation.get("minimum_qualitative_themes", 3))
minimum_quotes = int(validation.get("minimum_qualitative_quotes", 8))
if len(theme_keys) < minimum_themes or retained < minimum_quotes:
return f"incomplete_packet_themes_{len(theme_keys)}_quotes_{retained}"
if not has_user_data and not has_deviant:
return "generated_packet_missing_deviant_evidence"
return ""
def _generate_qualitative_packet(self, topic, keywords, raw_interviews, *, has_user_data):
"""Generate and validate one evidence packet with one bounded fresh retry."""
system_prompt = (
"You are a rigorous qualitative research analyst. "
"Evidence fidelity has priority over narrative completeness."
)
base_prompt = self._build_request(topic, keywords, raw_interviews)
last_reason = "packet_not_json_object"
for local_attempt in range(2):
# API/auth/balance/cancellation/transport failures remain owned by
# the model client and are deliberately not converted into local
# content retries here. Only a completed but locally invalid packet
# reaches the second attempt.
if local_attempt == 0:
response = self.agent.call_deepseek(
system_prompt + "\n\nDIRECT OUTPUT MODE: Produce the complete evidence packet directly without extended hidden reasoning.",
base_prompt,
max_tokens=8000, temp=0.0, model_role="reasoning",
thinking=False, allow_fallback=False, response_format={"type": "json_object"},
logical_node='structured_json',
)
else:
retry_prompt = base_prompt + (
"\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
"JSON/schema/content validation. Generate a fresh complete qualitative evidence "
"packet from the ORIGINAL inputs above. Do not repair, continue, quote, or rely on "
"the previous response. Return exactly one complete JSON object matching the required "
"schema, with no Markdown or explanatory prose. Preserve every evidence-fidelity rule "
"from the original request, including verbatim quotation requirements when user material "
"was supplied."
)
response = self.agent.call_deepseek(
system_prompt,
retry_prompt,
max_tokens=8000,
temp=0.0,
model_role="reasoning",
thinking=False,
allow_fallback=False,
response_format={"type": "json_object"},
logical_node='structured_json',
)
response = re.sub(
r"<think>.*?</think>",
"",
str(response or ""),
flags=re.DOTALL,
).strip()
data = self.agent._extract_json_from_text(response)
last_reason = self._qualitative_packet_validation_error(
data,
has_user_data=has_user_data,
)
if not last_reason:
return data
if local_attempt == 0:
logging.warning(
"Qualitative evidence structured-output local retry | attempt=1/2 | reason=%s",
last_reason,
)
if last_reason == "packet_not_json_object":
raise RuntimeError("Qualitative evidence packet could not be parsed safely.")
if last_reason == "invalid_packet_schema":
raise RuntimeError("Qualitative evidence packet has an invalid schema.")
raise RuntimeError(
"Qualitative evidence packet failed local validation after one bounded regeneration: "
f"{last_reason}."
)
def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
# R3-Free engine code is headless. Streamlit rendering belongs to app.py;
# this method only produces frozen evidence + metadata.
data = self._generate_qualitative_packet(
topic, keywords, raw_interviews, has_user_data=has_user_data
)
facts = data.get("study_facts") or {}
evidence = data.get("evidence") or data.get("quotes") or []
data_sources = facts.get("data_sources") or ["Not specified"]
if not isinstance(data_sources, list):
data_sources = [data_sources]
setting = self._clean_cell(facts.get("research_setting"))
clean_sources = [self._clean_cell(item) for item in data_sources]
sources = ", ".join(clean_sources)
data_source_roles = self._normalize_data_source_roles(facts.get("data_source_roles") or [])
trustworthiness = self._normalize_string_list(facts.get("trustworthiness_procedures"))
ethics = self._normalize_string_list(facts.get("ethics_procedures"))
reflexivity = self._normalize_string_list(facts.get("reflexivity_procedures"))
participants = facts.get("participant_count")
interviews = facts.get("interview_count")
analytic_design = self._normalize_analytic_design(
facts.get("analytic_design") or facts.get("analysis_method")
)
analytic_logic = self._clean_cell(
facts.get("analytic_logic"),
"Iteratively compare empirical observations to refine a process/mechanism explanation.",
)
temporal_structure = self._clean_cell(facts.get("temporal_structure"))
case_structure = self._clean_cell(facts.get("case_structure"))
for value, label in ((participants, "participant_count"), (interviews, "interview_count")):
if value is not None and (not isinstance(value, int) or value <= 0):
raise RuntimeError(f"Qualitative evidence packet has invalid {label}.")
md = (
"### Frozen Qualitative Study Design\n"
"| Field | Frozen value |\n"
"| :--- | :--- |\n"
f"| Research setting | {setting} |\n"
f"| Data sources | {sources} |\n"
f"| Participants | {participants if participants is not None else 'Not specified'} |\n"
f"| Interviews | {interviews if interviews is not None else 'Not specified'} |\n"
f"| Analytic design | {analytic_design} |\n"
f"| Analytic logic | {analytic_logic} |\n"
f"| Temporal structure | {temporal_structure} |\n"
f"| Case structure | {case_structure} |\n"
f"| Trustworthiness procedures | {'; '.join(trustworthiness) if trustworthiness else 'Not specified'} |\n"
f"| Ethics procedures | {'; '.join(ethics) if ethics else 'Not specified'} |\n"
f"| Reflexivity procedures | {'; '.join(reflexivity) if reflexivity else 'Not specified'} |\n\n"
"[QUALITATIVE EVIDENCE CONTRACT]\n"
"Use the frozen design summary and tagged evidence below as the only source of this study's empirical facts. "
"Do not change the setting, sample, interview count, data sources, actor/data-source relations, analytic design, temporal structure, cases, procedures, or quotations. "
"Do not infer an event sequence, actor comparison, trustworthiness/ethics/reflexivity procedure, negative case, or quotation that is absent from this packet.\n\n"
"### Table: Qualitative Evidence Structure\n"
"| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |\n"
"| :--- | :--- | :--- | :--- | :--- | :--- |\n"
)
if data_source_roles:
md += (
"### Frozen Actor / Data-Source Relations\n"
"| Actor role | Data source | Count |\n"
"| :--- | :--- | :--- |\n"
)
for row in data_source_roles:
md += f"| {row['actor_role']} | {row['data_source']} | {row['count'] if row['count'] is not None else 'Not specified'} |\n"
md += "\n"
clean_evidence = []
seen_quotes = set()
for item in evidence[:15]:
if not isinstance(item, dict):
continue
quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
concept = self._clean_cell(item.get("concept"), "Empirical observation")
theme = self._clean_cell(item.get("theme"), "Emergent pattern")
stage = self._clean_cell(item.get("stage"))
case_label = self._clean_cell(item.get("case_label"))
evidence_role = str(item.get("evidence_role") or "typical").strip().lower()
if evidence_role not in {"typical", "deviant"}:
evidence_role = "typical"
normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
continue
seen_quotes.add(normalized_quote)
tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
md += (
f'| {theme} | {concept} | {stage} | {case_label} | {evidence_role} | '
f'"{quote}" [{tag}] |\n'
)
clean_evidence.append(
{
"theme": theme,
"concept": concept,
"quote": quote,
"quote_hash": hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest(),
"tag": tag,
"stage": stage,
"case_label": case_label,
"evidence_role": evidence_role,
}
)
theme_map = {}
theme_ids = {}
for item in clean_evidence:
theme_key = re.sub(r"\s+", " ", item["theme"]).strip().casefold()
theme_id = theme_ids.setdefault(theme_key, f"THEME-{len(theme_ids) + 1:02d}")
item["theme_id"] = theme_id
theme_entry = theme_map.setdefault(
item["theme"],
{
"theme_id": theme_id,
"title": item["theme"],
"concepts": [],
"evidence": [],
"stages": [],
"has_deviant_evidence": False,
},
)
if item["concept"] not in theme_entry["concepts"]:
theme_entry["concepts"].append(item["concept"])
if item["stage"] != "Not specified" and item["stage"] not in theme_entry["stages"]:
theme_entry["stages"].append(item["stage"])
if item["evidence_role"] == "deviant":
theme_entry["has_deviant_evidence"] = True
theme_entry["evidence"].append(
{
"theme_id": item["theme_id"],
"tag": item["tag"],
"quote": item["quote"],
"quote_hash": item["quote_hash"],
"stage": item["stage"],
"case_label": item["case_label"],
"evidence_role": item["evidence_role"],
}
)
themes = list(theme_map.values())
validation = APP_CONFIG.get("manuscript_validation", {})
minimum_themes = int(validation.get("minimum_qualitative_themes", 3))
minimum_quotes = int(validation.get("minimum_qualitative_quotes", 8))
if len(themes) < minimum_themes or len(clean_evidence) < minimum_quotes:
raise RuntimeError(
"Qualitative evidence packet is incomplete: "
f"themes={len(themes)}, quotes={len(clean_evidence)}; "
f"required themes>={minimum_themes}, quotes>={minimum_quotes}."
)
if not has_user_data and not any(item["evidence_role"] == "deviant" for item in clean_evidence):
raise RuntimeError("Generated qualitative evidence packet lacks a deviant/boundary observation.")
metadata = {
"mode": "qualitative",
"source_type": (
"user_provided" if has_user_data else "system_generated"
),
"disclosure_required": not has_user_data,
"study_facts": {
"research_setting": setting,
"data_sources": clean_sources,
"data_source_roles": data_source_roles,
"participant_count": participants,
"interview_count": interviews,
"trustworthiness_procedures": trustworthiness,
"ethics_procedures": ethics,
"reflexivity_procedures": reflexivity,
"analytic_design": analytic_design,
"analysis_method": analytic_design,
"analytic_logic": analytic_logic,
"temporal_structure": temporal_structure,
"case_structure": case_structure,
},
"evidence_count": len(clean_evidence),
"generation_parameters": {
"analytic_design": analytic_design,
"requested_quote_count_when_generated": 12,
"maximum_retained_quotes": 15,
"minimum_themes": minimum_themes,
"minimum_quotes": minimum_quotes,
"requires_generated_deviant_evidence": not has_user_data,
"consistency_constraints": [
"one frozen research setting",
"one frozen participant count",
"one frozen interview count",
"one frozen data-source set",
"one frozen analytic design",
"no unsupported temporal or case structure",
"quotation tags must remain unique",
],
},
"evidence": clean_evidence,
"themes": themes,
}
metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
status_message = "✅ 定性研究事实、分析设计与证据结构已冻结。"
return md, status_message, metadata
def get_qualitative_engine(agent):
return QualitativeEngine(agent)