Spaces:
Running
Running
A5-R3-R7-R2C-R5 user research intent authority scoped deployment
#120
by GuXiaopeng - opened
- core/generation_runner.py +140 -16
- core/pipeline_agent.py +26 -4
- core/qualitative_engine.py +47 -2
- core/quantitative_engine.py +11 -4
- core/study_design_contract.py +318 -4
core/generation_runner.py
CHANGED
|
@@ -32,6 +32,7 @@ from core.production_guard import build_validated_package, replace_abstract
|
|
| 32 |
from core.qualitative_engine import get_qualitative_engine
|
| 33 |
from core.quantitative_engine import get_quantitative_engine
|
| 34 |
from core.rag_engine import initialize_rag
|
|
|
|
| 35 |
from utils.file_parser import extract_text_from_files
|
| 36 |
|
| 37 |
ProgressCallback = Callable[..., None]
|
|
@@ -1796,10 +1797,19 @@ class GenerationRunner:
|
|
| 1796 |
|
| 1797 |
@staticmethod
|
| 1798 |
def _canonical_quantitative_front_matter_keywords(mode: str, candidate_keywords, data_metadata: dict):
|
| 1799 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1800 |
values = []
|
| 1801 |
seen = set()
|
| 1802 |
-
for item in
|
| 1803 |
value = re.sub(r"\s+", " ", str(item or "")).strip()
|
| 1804 |
key = value.casefold()
|
| 1805 |
if value and key not in seen:
|
|
@@ -1842,6 +1852,85 @@ class GenerationRunner:
|
|
| 1842 |
return value[:title_match.end()] + "\n\n" + block + value[title_match.end():].lstrip("\n")
|
| 1843 |
return block + value.lstrip("\n")
|
| 1844 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1845 |
@staticmethod
|
| 1846 |
def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
|
| 1847 |
relevant = [
|
|
@@ -1890,10 +1979,19 @@ class GenerationRunner:
|
|
| 1890 |
items.append({'source': term, 'kind': str(kind or 'concept'), 'policy': normalized_policy})
|
| 1891 |
|
| 1892 |
meta = dict(data_metadata or {})
|
|
|
|
|
|
|
| 1893 |
if str(mode or '') == 'amj':
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1897 |
else:
|
| 1898 |
for theme in (meta.get('themes') or []):
|
| 1899 |
if not isinstance(theme, dict):
|
|
@@ -1911,14 +2009,15 @@ class GenerationRunner:
|
|
| 1911 |
for concept in (task.get('concepts') or []):
|
| 1912 |
add(concept, 'outline_concept', 'FLEXIBLE_TERM')
|
| 1913 |
|
| 1914 |
-
#
|
| 1915 |
-
#
|
| 1916 |
-
#
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
| 1921 |
-
|
|
|
|
| 1922 |
|
| 1923 |
# Capture explicit lower-case hyphenated academic expressions only when
|
| 1924 |
# they recur or carry a research-concept cue. This closes the
|
|
@@ -1937,7 +2036,13 @@ class GenerationRunner:
|
|
| 1937 |
if counts.get(term.casefold(), 0) >= 2 or parts.intersection(concept_cues):
|
| 1938 |
add(term, 'logic_hyphenated_concept', 'FLEXIBLE_TERM')
|
| 1939 |
|
| 1940 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1941 |
|
| 1942 |
@staticmethod
|
| 1943 |
def _canonical_concept_writer_directive(ledger: dict) -> str:
|
|
@@ -1946,12 +2051,21 @@ class GenerationRunner:
|
|
| 1946 |
if not terms:
|
| 1947 |
return ''
|
| 1948 |
rendered = '; '.join(terms[:40])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1949 |
return (
|
| 1950 |
'CANONICAL CONCEPT IDENTITY: The following research labels are frozen task-level concepts: '
|
| 1951 |
+ rendered
|
| 1952 |
+ '. When referring to the same construct or concept, preserve its canonical English label; '
|
| 1953 |
'do not silently replace it with a near-synonym that changes concept identity. Ordinary grammatical '
|
| 1954 |
'articles, possessives, and surrounding syntax remain free. Distinct listed labels are not synonyms unless the evidence explicitly says so.'
|
|
|
|
| 1955 |
)
|
| 1956 |
|
| 1957 |
@staticmethod
|
|
@@ -3443,7 +3557,9 @@ class GenerationRunner:
|
|
| 3443 |
|
| 3444 |
self._progress(14, "outline_planning")
|
| 3445 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 3446 |
-
|
|
|
|
|
|
|
| 3447 |
structure = plan.get("structure", [])
|
| 3448 |
full_draft_en = (
|
| 3449 |
f"# {en_topic}\n\n## Abstract\n{working_abstract}\n\n"
|
|
@@ -5190,7 +5306,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 5190 |
"examples":final_front_fact_rows[:3],
|
| 5191 |
})
|
| 5192 |
state.pop("final_abstract_retry_exhausted", None)
|
| 5193 |
-
final_title = str(topic or "").strip()
|
| 5194 |
if mode == "amj" and str(data_metadata.get("source_type") or "") == "system_generated":
|
| 5195 |
semantic_contract = dict(data_metadata.get("front_matter_contract") or {})
|
| 5196 |
reconciled_front = agent.reconcile_quantitative_reader_front_matter(
|
|
@@ -5456,6 +5572,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 5456 |
full_draft_translated = _normalize_target_manuscript_typography(
|
| 5457 |
full_draft_translated, target_lang
|
| 5458 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5459 |
if mode == "asq":
|
| 5460 |
appendix_manifest = qual_engine.build_appendix_manifest(data_metadata)
|
| 5461 |
try:
|
|
|
|
| 32 |
from core.qualitative_engine import get_qualitative_engine
|
| 33 |
from core.quantitative_engine import get_quantitative_engine
|
| 34 |
from core.rag_engine import initialize_rag
|
| 35 |
+
from core.study_design_contract import build_user_research_intent
|
| 36 |
from utils.file_parser import extract_text_from_files
|
| 37 |
|
| 38 |
ProgressCallback = Callable[..., None]
|
|
|
|
| 1797 |
|
| 1798 |
@staticmethod
|
| 1799 |
def _canonical_quantitative_front_matter_keywords(mode: str, candidate_keywords, data_metadata: dict):
|
| 1800 |
+
"""Return user-authoritative keywords when available; otherwise normalize a legacy candidate.
|
| 1801 |
+
|
| 1802 |
+
The name is retained for checkpoint compatibility, but R7-R2C-R5 applies
|
| 1803 |
+
the same authority rule to quantitative and qualitative manuscripts.
|
| 1804 |
+
"""
|
| 1805 |
+
meta = dict(data_metadata or {})
|
| 1806 |
+
contract = dict(meta.get("study_design_contract") or {})
|
| 1807 |
+
intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
|
| 1808 |
+
authoritative = list(intent.get("source_keywords") or contract.get("source_keywords") or [])
|
| 1809 |
+
source = authoritative if authoritative else list(candidate_keywords or [])
|
| 1810 |
values = []
|
| 1811 |
seen = set()
|
| 1812 |
+
for item in source:
|
| 1813 |
value = re.sub(r"\s+", " ", str(item or "")).strip()
|
| 1814 |
key = value.casefold()
|
| 1815 |
if value and key not in seen:
|
|
|
|
| 1852 |
return value[:title_match.end()] + "\n\n" + block + value[title_match.end():].lstrip("\n")
|
| 1853 |
return block + value.lstrip("\n")
|
| 1854 |
|
| 1855 |
+
|
| 1856 |
+
@staticmethod
|
| 1857 |
+
def _target_keyword_heading_aliases(target_lang: str) -> list[str]:
|
| 1858 |
+
code = str(target_lang or '').strip().casefold().replace('_', '-')
|
| 1859 |
+
if code.startswith('zh'):
|
| 1860 |
+
return ['关键词', '关键字']
|
| 1861 |
+
if code.startswith('ru'):
|
| 1862 |
+
return ['Ключевые слова']
|
| 1863 |
+
if code.startswith('kk') or code.startswith('kz'):
|
| 1864 |
+
return ['Түйін сөздер', 'Кілт сөздер']
|
| 1865 |
+
return ['Keywords']
|
| 1866 |
+
|
| 1867 |
+
@classmethod
|
| 1868 |
+
def _rebind_target_user_intent_front_matter(cls, manuscript: str, target_lang: str, data_metadata: dict, glossary: dict):
|
| 1869 |
+
"""Preserve user keyword identity and explicit relation type in target front matter."""
|
| 1870 |
+
value = str(manuscript or '')
|
| 1871 |
+
meta = dict(data_metadata or {})
|
| 1872 |
+
contract = dict(meta.get('study_design_contract') or {})
|
| 1873 |
+
intent = dict(contract.get('user_research_intent') or meta.get('user_research_intent') or {})
|
| 1874 |
+
source_keywords = list(intent.get('source_keywords') or contract.get('source_keywords') or [])
|
| 1875 |
+
mapping = dict(glossary or {})
|
| 1876 |
+
diagnostics = []
|
| 1877 |
+
|
| 1878 |
+
if source_keywords:
|
| 1879 |
+
target_keywords = [re.sub(r'\s+', ' ', str(mapping.get(src) or '')).strip() for src in source_keywords]
|
| 1880 |
+
if all(target_keywords):
|
| 1881 |
+
aliases = cls._target_keyword_heading_aliases(target_lang)
|
| 1882 |
+
heading_re = re.compile(r'(?mi)^##\s+(?:' + '|'.join(re.escape(x) for x in aliases) + r')\s*$')
|
| 1883 |
+
match = heading_re.search(value)
|
| 1884 |
+
if match:
|
| 1885 |
+
next_heading = re.search(r'(?m)^#{1,3}\s+', value[match.end():])
|
| 1886 |
+
end = match.end() + (next_heading.start() if next_heading else len(value[match.end():]))
|
| 1887 |
+
rendered = ', '.join(target_keywords)
|
| 1888 |
+
value = value[:match.end()] + '\n' + rendered + '\n\n' + value[end:].lstrip('\n')
|
| 1889 |
+
diagnostics.append({'type':'target_user_keywords_rebound','count':len(target_keywords)})
|
| 1890 |
+
|
| 1891 |
+
explicit = set(intent.get('explicit_relation_types') or [])
|
| 1892 |
+
if explicit:
|
| 1893 |
+
title_match = re.search(r'(?m)^#\s+(.+?)\s*$', value)
|
| 1894 |
+
if title_match:
|
| 1895 |
+
title = title_match.group(1).strip()
|
| 1896 |
+
target_intent = build_user_research_intent(title, '', mode=str(contract.get('mode') or ''))
|
| 1897 |
+
missing = explicit - set(target_intent.get('explicit_relation_types') or [])
|
| 1898 |
+
if missing:
|
| 1899 |
+
quant = dict(contract.get('quantitative') or {})
|
| 1900 |
+
construct_rows = [row for row in (quant.get('constructs') or []) if isinstance(row, dict)]
|
| 1901 |
+
role_label = {}
|
| 1902 |
+
for role in ('mediator', 'moderator'):
|
| 1903 |
+
values = [str(row.get('label') or '') for row in construct_rows if role in (row.get('roles') or []) and row.get('label')]
|
| 1904 |
+
if len(values) == 1:
|
| 1905 |
+
role_label[role] = re.sub(r'\s+', ' ', str(mapping.get(values[0]) or values[0])).strip()
|
| 1906 |
+
code = str(target_lang or '').strip().casefold().replace('_', '-')
|
| 1907 |
+
suffixes = []
|
| 1908 |
+
if 'moderation' in missing:
|
| 1909 |
+
label = role_label.get('moderator') or ''
|
| 1910 |
+
if code.startswith('zh'):
|
| 1911 |
+
suffixes.append(f'调节变量:{label}' if label else '调节作用')
|
| 1912 |
+
elif code.startswith('ru'):
|
| 1913 |
+
suffixes.append(f'модератор: {label}' if label else 'модерация')
|
| 1914 |
+
elif code.startswith('kk') or code.startswith('kz'):
|
| 1915 |
+
suffixes.append(f'модератор: {label}' if label else 'модераторлық рөл')
|
| 1916 |
+
else:
|
| 1917 |
+
suffixes.append(f'moderator: {label}' if label else 'moderation')
|
| 1918 |
+
if missing.intersection({'mediation', 'serial_mediation', 'parallel_mediation'}):
|
| 1919 |
+
label = role_label.get('mediator') or ''
|
| 1920 |
+
if code.startswith('zh'):
|
| 1921 |
+
suffixes.append(f'中介变量:{label}' if label else '中介作用')
|
| 1922 |
+
elif code.startswith('ru'):
|
| 1923 |
+
suffixes.append(f'медиатор: {label}' if label else 'медиация')
|
| 1924 |
+
elif code.startswith('kk') or code.startswith('kz'):
|
| 1925 |
+
suffixes.append(f'медиатор: {label}' if label else 'медиаторлық рөл')
|
| 1926 |
+
else:
|
| 1927 |
+
suffixes.append(f'mediator: {label}' if label else 'mediation')
|
| 1928 |
+
if suffixes:
|
| 1929 |
+
repaired_title = title.rstrip(' .;:') + ' (' + '; '.join(suffixes) + ')'
|
| 1930 |
+
value = value[:title_match.start(1)] + repaired_title + value[title_match.end(1):]
|
| 1931 |
+
diagnostics.append({'type':'target_user_relation_title_rebound','missing':sorted(missing)})
|
| 1932 |
+
return value, diagnostics
|
| 1933 |
+
|
| 1934 |
@staticmethod
|
| 1935 |
def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
|
| 1936 |
relevant = [
|
|
|
|
| 1979 |
items.append({'source': term, 'kind': str(kind or 'concept'), 'policy': normalized_policy})
|
| 1980 |
|
| 1981 |
meta = dict(data_metadata or {})
|
| 1982 |
+
contract = dict(meta.get('study_design_contract') or {})
|
| 1983 |
+
user_intent = dict(contract.get('user_research_intent') or meta.get('user_research_intent') or {})
|
| 1984 |
if str(mode or '') == 'amj':
|
| 1985 |
+
construct_items = [row for row in (meta.get('construct_items') or []) if isinstance(row, dict)]
|
| 1986 |
+
if construct_items:
|
| 1987 |
+
for row in construct_items:
|
| 1988 |
+
roles = list(row.get('roles') or [])
|
| 1989 |
+
role_label = '_'.join(str(role) for role in roles if str(role)) or 'construct'
|
| 1990 |
+
add(row.get('label'), f'quantitative_{role_label}', 'STRICT_CONSTRUCT')
|
| 1991 |
+
else:
|
| 1992 |
+
constructs = meta.get('constructs') if isinstance(meta.get('constructs'), dict) else {}
|
| 1993 |
+
for role in ('predictor', 'mediator', 'moderator', 'outcome', 'control', 'covariate'):
|
| 1994 |
+
add(constructs.get(role), f'quantitative_{role}', 'STRICT_CONSTRUCT')
|
| 1995 |
else:
|
| 1996 |
for theme in (meta.get('themes') or []):
|
| 1997 |
if not isinstance(theme, dict):
|
|
|
|
| 2009 |
for concept in (task.get('concepts') or []):
|
| 2010 |
add(concept, 'outline_concept', 'FLEXIBLE_TERM')
|
| 2011 |
|
| 2012 |
+
# R7-R2C-R5: user keywords are research-intent identities, not expendable
|
| 2013 |
+
# planning seeds. Freeze them for multilingual inheritance while still
|
| 2014 |
+
# distinguishing them from empirical construct roles.
|
| 2015 |
+
intent_keywords = list(user_intent.get('source_keywords') or [])
|
| 2016 |
+
if not intent_keywords:
|
| 2017 |
+
intent_keywords = [seed.strip() for seed in re.split(r'[,;\n|]+', str(keywords or '')) if seed.strip()]
|
| 2018 |
+
for seed in intent_keywords:
|
| 2019 |
+
if 1 <= len(str(seed).split()) <= 10:
|
| 2020 |
+
add(seed, 'user_intent_keyword', 'STRICT_CONSTRUCT')
|
| 2021 |
|
| 2022 |
# Capture explicit lower-case hyphenated academic expressions only when
|
| 2023 |
# they recur or carry a research-concept cue. This closes the
|
|
|
|
| 2036 |
if counts.get(term.casefold(), 0) >= 2 or parts.intersection(concept_cues):
|
| 2037 |
add(term, 'logic_hyphenated_concept', 'FLEXIBLE_TERM')
|
| 2038 |
|
| 2039 |
+
return {
|
| 2040 |
+
'version': 'concept-ledger-v2',
|
| 2041 |
+
'items': items,
|
| 2042 |
+
'explicit_relation_types': list(user_intent.get('explicit_relation_types') or []),
|
| 2043 |
+
'source_topic': str(user_intent.get('source_topic') or contract.get('source_topic') or ''),
|
| 2044 |
+
'source_keywords': list(user_intent.get('source_keywords') or contract.get('source_keywords') or []),
|
| 2045 |
+
}
|
| 2046 |
|
| 2047 |
@staticmethod
|
| 2048 |
def _canonical_concept_writer_directive(ledger: dict) -> str:
|
|
|
|
| 2051 |
if not terms:
|
| 2052 |
return ''
|
| 2053 |
rendered = '; '.join(terms[:40])
|
| 2054 |
+
relation_types = [str(item).strip() for item in (dict(ledger or {}).get('explicit_relation_types') or []) if str(item).strip()]
|
| 2055 |
+
relation_directive = ''
|
| 2056 |
+
if relation_types:
|
| 2057 |
+
relation_directive = (
|
| 2058 |
+
' USER RESEARCH INTENT: Preserve these explicitly requested study relations throughout drafting and target-language translation: '
|
| 2059 |
+
+ ', '.join(relation_types)
|
| 2060 |
+
+ '. Do not downgrade a mediator/moderator or other explicit relation into a generic contextual term merely because direct prior-literature support is sparse. Prior-literature claims still require real citation evidence; novel study logic may be stated cautiously as author inference without inventing a source.'
|
| 2061 |
+
)
|
| 2062 |
return (
|
| 2063 |
'CANONICAL CONCEPT IDENTITY: The following research labels are frozen task-level concepts: '
|
| 2064 |
+ rendered
|
| 2065 |
+ '. When referring to the same construct or concept, preserve its canonical English label; '
|
| 2066 |
'do not silently replace it with a near-synonym that changes concept identity. Ordinary grammatical '
|
| 2067 |
'articles, possessives, and surrounding syntax remain free. Distinct listed labels are not synonyms unless the evidence explicitly says so.'
|
| 2068 |
+
+ relation_directive
|
| 2069 |
)
|
| 2070 |
|
| 2071 |
@staticmethod
|
|
|
|
| 3557 |
|
| 3558 |
self._progress(14, "outline_planning")
|
| 3559 |
plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
|
| 3560 |
+
# User title is reader-facing authority. Outline planning may adapt
|
| 3561 |
+
# structure but may not silently rename the study.
|
| 3562 |
+
en_topic = str(source_topic or topic).strip()
|
| 3563 |
structure = plan.get("structure", [])
|
| 3564 |
full_draft_en = (
|
| 3565 |
f"# {en_topic}\n\n## Abstract\n{working_abstract}\n\n"
|
|
|
|
| 5306 |
"examples":final_front_fact_rows[:3],
|
| 5307 |
})
|
| 5308 |
state.pop("final_abstract_retry_exhausted", None)
|
| 5309 |
+
final_title = str(source_topic or topic or "").strip()
|
| 5310 |
if mode == "amj" and str(data_metadata.get("source_type") or "") == "system_generated":
|
| 5311 |
semantic_contract = dict(data_metadata.get("front_matter_contract") or {})
|
| 5312 |
reconciled_front = agent.reconcile_quantitative_reader_front_matter(
|
|
|
|
| 5572 |
full_draft_translated = _normalize_target_manuscript_typography(
|
| 5573 |
full_draft_translated, target_lang
|
| 5574 |
)
|
| 5575 |
+
full_draft_translated, intent_front_diagnostics = self._rebind_target_user_intent_front_matter(
|
| 5576 |
+
full_draft_translated, target_lang, data_metadata, translation_glossary
|
| 5577 |
+
)
|
| 5578 |
+
for diagnostic in intent_front_diagnostics:
|
| 5579 |
+
self._record_adaptive_completion_residual(
|
| 5580 |
+
quality_notes, [diagnostic], section_name='Target-language front matter',
|
| 5581 |
+
stage='user_research_intent_translation_recovery'
|
| 5582 |
+
)
|
| 5583 |
if mode == "asq":
|
| 5584 |
appendix_manifest = qual_engine.build_appendix_manifest(data_metadata)
|
| 5585 |
try:
|
core/pipeline_agent.py
CHANGED
|
@@ -1838,6 +1838,27 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
|
|
| 1838 |
return issues
|
| 1839 |
|
| 1840 |
def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1841 |
"""Adaptively reconcile final AMJ title/keywords to frozen evidence.
|
| 1842 |
|
| 1843 |
Original user topic/keyword seeds are retained as research context. The
|
|
@@ -1954,6 +1975,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
|
|
| 1954 |
logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
|
| 1955 |
return fallback
|
| 1956 |
|
|
|
|
| 1957 |
def generate_working_front_matter(
|
| 1958 |
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
|
| 1959 |
):
|
|
@@ -1964,9 +1986,9 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
|
|
| 1964 |
prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
|
| 1965 |
Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
|
| 1966 |
Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
|
| 1967 |
-
|
| 1968 |
TOPIC: {topic}
|
| 1969 |
-
USER
|
| 1970 |
<PROVISIONAL_RESEARCH_LOGIC>
|
| 1971 |
{logic}
|
| 1972 |
</PROVISIONAL_RESEARCH_LOGIC>
|
|
@@ -2000,9 +2022,9 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
|
|
| 2000 |
prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
|
| 2001 |
Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
|
| 2002 |
Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
|
| 2003 |
-
|
| 2004 |
TOPIC: {topic}
|
| 2005 |
-
USER
|
| 2006 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 2007 |
{evidence}
|
| 2008 |
</FROZEN_RESEARCH_EVIDENCE>
|
|
|
|
| 1838 |
return issues
|
| 1839 |
|
| 1840 |
def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
|
| 1841 |
+
"""Preserve R5 user intent while retaining legacy contract reconciliation.
|
| 1842 |
+
|
| 1843 |
+
Contracts that carry ``user_research_intent`` are authoritative: the
|
| 1844 |
+
finalizer must not repair an upstream design mismatch by silently
|
| 1845 |
+
renaming the user study. Older contracts without that explicit R5
|
| 1846 |
+
authority retain the pre-R5 bounded reconciliation path so historical
|
| 1847 |
+
checkpoints and compatibility behavior remain stable.
|
| 1848 |
+
"""
|
| 1849 |
+
record = dict(contract or {})
|
| 1850 |
+
intent = dict(record.get("user_research_intent") or {})
|
| 1851 |
+
if intent:
|
| 1852 |
+
source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
|
| 1853 |
+
source_keywords = list(intent.get("source_keywords") or record.get("source_keywords") or [])
|
| 1854 |
+
if not source_keywords:
|
| 1855 |
+
source_keywords = [item.strip() for item in re.split(r"[,;\n|]+", str(keywords or "")) if item.strip()]
|
| 1856 |
+
return {
|
| 1857 |
+
"title": source_topic,
|
| 1858 |
+
"keywords": [re.sub(r"\s+", " ", str(item or "")).strip() for item in source_keywords if str(item or "").strip()],
|
| 1859 |
+
}
|
| 1860 |
+
|
| 1861 |
+
# Legacy compatibility: no R5 user-intent authority was frozen.
|
| 1862 |
"""Adaptively reconcile final AMJ title/keywords to frozen evidence.
|
| 1863 |
|
| 1864 |
Original user topic/keyword seeds are retained as research context. The
|
|
|
|
| 1975 |
logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
|
| 1976 |
return fallback
|
| 1977 |
|
| 1978 |
+
|
| 1979 |
def generate_working_front_matter(
|
| 1980 |
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
|
| 1981 |
):
|
|
|
|
| 1986 |
prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
|
| 1987 |
Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
|
| 1988 |
Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
|
| 1989 |
+
Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
|
| 1990 |
TOPIC: {topic}
|
| 1991 |
+
USER KEYWORDS: {keywords}
|
| 1992 |
<PROVISIONAL_RESEARCH_LOGIC>
|
| 1993 |
{logic}
|
| 1994 |
</PROVISIONAL_RESEARCH_LOGIC>
|
|
|
|
| 2022 |
prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
|
| 2023 |
Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
|
| 2024 |
Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
|
| 2025 |
+
Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
|
| 2026 |
TOPIC: {topic}
|
| 2027 |
+
USER KEYWORDS: {keywords}
|
| 2028 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 2029 |
{evidence}
|
| 2030 |
</FROZEN_RESEARCH_EVIDENCE>
|
core/qualitative_engine.py
CHANGED
|
@@ -9,7 +9,11 @@ import logging
|
|
| 9 |
import re
|
| 10 |
|
| 11 |
from config import APP_CONFIG
|
| 12 |
-
from core.study_design_contract import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class QualitativeEngine:
|
|
@@ -568,7 +572,28 @@ class QualitativeEngine:
|
|
| 568 |
)
|
| 569 |
return "\n".join(lines).strip()
|
| 570 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
def _build_request(self, topic, keywords, raw_interviews):
|
|
|
|
|
|
|
| 572 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 573 |
{
|
| 574 |
"study_facts": {
|
|
@@ -601,9 +626,11 @@ Allowed analytic designs: Process analysis; Comparative case analysis; Grounded
|
|
| 601 |
if raw_interviews and len(raw_interviews) > 100:
|
| 602 |
return f"""Topic: {topic}
|
| 603 |
Keywords: {keywords}
|
|
|
|
| 604 |
Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
|
| 605 |
|
| 606 |
Rules:
|
|
|
|
| 607 |
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.
|
| 608 |
2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
|
| 609 |
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.
|
|
@@ -619,9 +646,11 @@ Rules:
|
|
| 619 |
|
| 620 |
return f"""Topic: {topic}
|
| 621 |
Keywords: {keywords}
|
|
|
|
| 622 |
Construct one internally coherent qualitative evidence packet for a complete ASQ-oriented manuscript because no interview material was supplied.
|
| 623 |
|
| 624 |
Rules:
|
|
|
|
| 625 |
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.
|
| 626 |
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.
|
| 627 |
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.
|
|
@@ -635,7 +664,7 @@ Rules:
|
|
| 635 |
{schema}
|
| 636 |
"""
|
| 637 |
|
| 638 |
-
def _qualitative_packet_validation_error(self, data, *, has_user_data):
|
| 639 |
"""Return a bounded local validation reason, or an empty string when safe.
|
| 640 |
|
| 641 |
This pre-freeze validator mirrors the existing fatal packet gates so a
|
|
@@ -688,6 +717,19 @@ Rules:
|
|
| 688 |
if not (facts.get(key) or []):
|
| 689 |
return f"generated_packet_missing_{key}"
|
| 690 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
seen_quotes = set()
|
| 692 |
theme_keys = set()
|
| 693 |
retained = 0
|
|
@@ -721,6 +763,7 @@ Rules:
|
|
| 721 |
"You are a rigorous qualitative research analyst. "
|
| 722 |
"Evidence fidelity has priority over narrative completeness."
|
| 723 |
)
|
|
|
|
| 724 |
base_prompt = self._build_request(topic, keywords, raw_interviews)
|
| 725 |
last_reason = "packet_not_json_object"
|
| 726 |
|
|
@@ -769,6 +812,7 @@ Rules:
|
|
| 769 |
last_reason = self._qualitative_packet_validation_error(
|
| 770 |
data,
|
| 771 |
has_user_data=has_user_data,
|
|
|
|
| 772 |
)
|
| 773 |
if not last_reason:
|
| 774 |
return data
|
|
@@ -983,6 +1027,7 @@ Rules:
|
|
| 983 |
},
|
| 984 |
"evidence": clean_evidence,
|
| 985 |
"themes": themes,
|
|
|
|
| 986 |
}
|
| 987 |
metadata["study_design_contract"] = build_qualitative_contract(topic, keywords, metadata)
|
| 988 |
metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
|
|
|
|
| 9 |
import re
|
| 10 |
|
| 11 |
from config import APP_CONFIG
|
| 12 |
+
from core.study_design_contract import (
|
| 13 |
+
STUDY_DESIGN_CONTRACT_VERSION,
|
| 14 |
+
build_qualitative_contract,
|
| 15 |
+
build_user_research_intent,
|
| 16 |
+
)
|
| 17 |
|
| 18 |
|
| 19 |
class QualitativeEngine:
|
|
|
|
| 572 |
)
|
| 573 |
return "\n".join(lines).strip()
|
| 574 |
|
| 575 |
+
@staticmethod
|
| 576 |
+
def _intent_data_source_family(value):
|
| 577 |
+
folded = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
|
| 578 |
+
if re.search(r"interview|访谈|интервью|сұхбат", folded):
|
| 579 |
+
return "interview"
|
| 580 |
+
if re.search(r"focus.{0,4}(?:group|групп|топ)|焦点小组", folded):
|
| 581 |
+
return "focus_group"
|
| 582 |
+
if re.search(r"observ|观察|наблюден|бақылау", folded):
|
| 583 |
+
return "observation"
|
| 584 |
+
if re.search(r"archiv|document|档案|文档|文件|архив|документ|мұрағат|құжат", folded):
|
| 585 |
+
return "document"
|
| 586 |
+
if re.search(r"field.{0,4}note|田野笔记|полев.{0,6}замет|далалық.{0,6}жазб", folded):
|
| 587 |
+
return "field_note"
|
| 588 |
+
if re.search(r"digital.{0,4}trace|数字痕迹|цифров.{0,6}след|цифрлық.{0,6}із", folded):
|
| 589 |
+
return "digital_trace"
|
| 590 |
+
if re.search(r"\blog(?:s)?\b|日志|\bлог\b", folded):
|
| 591 |
+
return "log"
|
| 592 |
+
return "other"
|
| 593 |
+
|
| 594 |
def _build_request(self, topic, keywords, raw_interviews):
|
| 595 |
+
user_intent = build_user_research_intent(topic, keywords, mode="qualitative")
|
| 596 |
+
intent_block = json.dumps(user_intent, ensure_ascii=False)
|
| 597 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 598 |
{
|
| 599 |
"study_facts": {
|
|
|
|
| 626 |
if raw_interviews and len(raw_interviews) > 100:
|
| 627 |
return f"""Topic: {topic}
|
| 628 |
Keywords: {keywords}
|
| 629 |
+
<USER_RESEARCH_INTENT>{intent_block}</USER_RESEARCH_INTENT>
|
| 630 |
Analyze the supplied interview or field material for an ASQ-oriented qualitative manuscript.
|
| 631 |
|
| 632 |
Rules:
|
| 633 |
+
0. USER RESEARCH INTENT AUTHORITY: Preserve the user's research phenomenon, explicitly requested qualitative design, data-source family, temporal scope, actor/population, and context when the supplied material supports them. Never silently substitute a different method or phenomenon merely because literature or a generic template is easier. If the supplied material genuinely conflicts with the requested design, preserve evidence truth rather than inventing support; the deterministic validator will surface the conflict.
|
| 634 |
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.
|
| 635 |
2. Extract 8-15 representative quotations VERBATIM. Never repair, paraphrase, translate, combine, or invent a quotation.
|
| 636 |
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.
|
|
|
|
| 646 |
|
| 647 |
return f"""Topic: {topic}
|
| 648 |
Keywords: {keywords}
|
| 649 |
+
<USER_RESEARCH_INTENT>{intent_block}</USER_RESEARCH_INTENT>
|
| 650 |
Construct one internally coherent qualitative evidence packet for a complete ASQ-oriented manuscript because no interview material was supplied.
|
| 651 |
|
| 652 |
Rules:
|
| 653 |
+
0. USER RESEARCH INTENT AUTHORITY: The topic/keywords define what the study is asked to investigate. If they explicitly request an analytic design (for example grounded theory, thematic analysis, comparative case, process, longitudinal, or Gioia) or a data-source family (for example interviews, focus groups, observation, documents), preserve that request in the generated evidence packet. Sparse literature is not a reason to substitute another method.
|
| 654 |
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.
|
| 655 |
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.
|
| 656 |
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.
|
|
|
|
| 664 |
{schema}
|
| 665 |
"""
|
| 666 |
|
| 667 |
+
def _qualitative_packet_validation_error(self, data, *, has_user_data, user_intent=None):
|
| 668 |
"""Return a bounded local validation reason, or an empty string when safe.
|
| 669 |
|
| 670 |
This pre-freeze validator mirrors the existing fatal packet gates so a
|
|
|
|
| 717 |
if not (facts.get(key) or []):
|
| 718 |
return f"generated_packet_missing_{key}"
|
| 719 |
|
| 720 |
+
intent = dict(user_intent or {})
|
| 721 |
+
requested_design = str(intent.get("qualitative_design") or "").strip()
|
| 722 |
+
if requested_design:
|
| 723 |
+
observed_design = self._normalize_analytic_design(facts.get("analytic_design") or facts.get("analysis_method"))
|
| 724 |
+
if observed_design.casefold() != requested_design.casefold():
|
| 725 |
+
return f"user_intent_analytic_design_mismatch:{observed_design}->{requested_design}"
|
| 726 |
+
requested_sources = set(intent.get("qualitative_data_source_families") or [])
|
| 727 |
+
if requested_sources:
|
| 728 |
+
observed_sources = {self._intent_data_source_family(item) for item in (facts.get("data_sources") or [])}
|
| 729 |
+
missing_sources = sorted(requested_sources - observed_sources)
|
| 730 |
+
if missing_sources:
|
| 731 |
+
return "user_intent_data_source_mismatch:" + ",".join(missing_sources)
|
| 732 |
+
|
| 733 |
seen_quotes = set()
|
| 734 |
theme_keys = set()
|
| 735 |
retained = 0
|
|
|
|
| 763 |
"You are a rigorous qualitative research analyst. "
|
| 764 |
"Evidence fidelity has priority over narrative completeness."
|
| 765 |
)
|
| 766 |
+
user_intent = build_user_research_intent(topic, keywords, mode="qualitative")
|
| 767 |
base_prompt = self._build_request(topic, keywords, raw_interviews)
|
| 768 |
last_reason = "packet_not_json_object"
|
| 769 |
|
|
|
|
| 812 |
last_reason = self._qualitative_packet_validation_error(
|
| 813 |
data,
|
| 814 |
has_user_data=has_user_data,
|
| 815 |
+
user_intent=user_intent,
|
| 816 |
)
|
| 817 |
if not last_reason:
|
| 818 |
return data
|
|
|
|
| 1027 |
},
|
| 1028 |
"evidence": clean_evidence,
|
| 1029 |
"themes": themes,
|
| 1030 |
+
"user_research_intent": build_user_research_intent(topic, keywords, mode="qualitative"),
|
| 1031 |
}
|
| 1032 |
metadata["study_design_contract"] = build_qualitative_contract(topic, keywords, metadata)
|
| 1033 |
metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
|
core/quantitative_engine.py
CHANGED
|
@@ -19,6 +19,7 @@ from core.study_design_contract import (
|
|
| 19 |
validate_quantitative_contract,
|
| 20 |
compile_quantitative_hypothesis_ledger,
|
| 21 |
build_quantitative_front_matter_contract,
|
|
|
|
| 22 |
)
|
| 23 |
|
| 24 |
|
|
@@ -473,6 +474,7 @@ class QuantitativeEngine:
|
|
| 473 |
The existing ``structured_json`` logical node is intentionally reused so
|
| 474 |
Standard/Flash and Flagship/Pro ModelPolicy routing remains unchanged.
|
| 475 |
"""
|
|
|
|
| 476 |
fallback_candidate = {
|
| 477 |
"constructs": [
|
| 478 |
{"label":"Focal Predictor","roles":["predictor"]},
|
|
@@ -500,8 +502,9 @@ Return ONLY valid JSON with this schema:
|
|
| 500 |
"background_concepts":["topic concept that is contextual rather than measured/modelled"]
|
| 501 |
}}
|
| 502 |
Rules:
|
| 503 |
-
|
| 504 |
-
|
|
|
|
| 505 |
3. A moderation relation means source × moderator is associated with target.
|
| 506 |
4. Keep the graph parsimonious and acyclic for ordinary cross-sectional regression interpretation.
|
| 507 |
5. Use concise construct labels suitable for a journal article.
|
|
@@ -526,8 +529,12 @@ Keywords: {keywords}"""
|
|
| 526 |
contract = build_quantitative_contract(topic, keywords, parsed if isinstance(parsed, dict) else fallback_candidate)
|
| 527 |
issues = validate_quantitative_contract(contract)
|
| 528 |
if issues:
|
| 529 |
-
logging.warning("Adaptive quantitative design validation
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
context = str(dict(contract.get("context") or {}).get("research_context") or "")
|
| 532 |
if re.search(r"\b(university|company|corporation|inc\.?|ltd\.?|country|china|usa|united states|europe|asia)\b", context, re.I):
|
| 533 |
fallback_context = "respondents with experience relevant to the focal management phenomenon"
|
|
|
|
| 19 |
validate_quantitative_contract,
|
| 20 |
compile_quantitative_hypothesis_ledger,
|
| 21 |
build_quantitative_front_matter_contract,
|
| 22 |
+
build_user_research_intent,
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
|
|
| 474 |
The existing ``structured_json`` logical node is intentionally reused so
|
| 475 |
Standard/Flash and Flagship/Pro ModelPolicy routing remains unchanged.
|
| 476 |
"""
|
| 477 |
+
user_intent = build_user_research_intent(topic, keywords, mode="quantitative")
|
| 478 |
fallback_candidate = {
|
| 479 |
"constructs": [
|
| 480 |
{"label":"Focal Predictor","roles":["predictor"]},
|
|
|
|
| 502 |
"background_concepts":["topic concept that is contextual rather than measured/modelled"]
|
| 503 |
}}
|
| 504 |
Rules:
|
| 505 |
+
0. USER RESEARCH INTENT AUTHORITY: The user's topic and keywords are deliberate research-design inputs. Never delete, replace, or downgrade an explicitly requested focal construct, mediation, moderation, serial/parallel mediation, control, population, or research context merely because direct literature support may be sparse. Literature evidence constrains claims about PRIOR research; it does not redefine what this study is asked to investigate.
|
| 506 |
+
1. Use 2-8 distinct constructs only when the topic actually requires them; do NOT force a mediator or moderator that the user did not request.
|
| 507 |
+
2. Preserve explicit mediation, moderation, serial/parallel mediation, multiple antecedents/outcomes, and controls when the topic clearly requests them. When the requested relation is novel or direct prior evidence is sparse, keep the relation in the study graph and let later hypothesis development use cautious author inference rather than inventing citation support.
|
| 508 |
3. A moderation relation means source × moderator is associated with target.
|
| 509 |
4. Keep the graph parsimonious and acyclic for ordinary cross-sectional regression interpretation.
|
| 510 |
5. Use concise construct labels suitable for a journal article.
|
|
|
|
| 529 |
contract = build_quantitative_contract(topic, keywords, parsed if isinstance(parsed, dict) else fallback_candidate)
|
| 530 |
issues = validate_quantitative_contract(contract)
|
| 531 |
if issues:
|
| 532 |
+
logging.warning("Adaptive quantitative design validation requires intent-preserving fallback: %s", issues[:3])
|
| 533 |
+
fallback = build_quantitative_contract(topic, keywords, fallback_candidate)
|
| 534 |
+
fallback_issues = validate_quantitative_contract(fallback)
|
| 535 |
+
if fallback_issues:
|
| 536 |
+
raise RuntimeError(f"quantitative_user_intent_unresolved:{fallback_issues[:3]}")
|
| 537 |
+
return fallback
|
| 538 |
context = str(dict(contract.get("context") or {}).get("research_context") or "")
|
| 539 |
if re.search(r"\b(university|company|corporation|inc\.?|ltd\.?|country|china|usa|united states|europe|asia)\b", context, re.I):
|
| 540 |
fallback_context = "respondents with experience relevant to the focal management phenomenon"
|
core/study_design_contract.py
CHANGED
|
@@ -40,6 +40,290 @@ def _keywords(value: Any) -> list[str]:
|
|
| 40 |
return out[:16]
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def _stable_hash(payload: dict[str, Any]) -> str:
|
| 44 |
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
| 45 |
return hashlib.sha256(encoded).hexdigest()
|
|
@@ -178,6 +462,16 @@ def build_quantitative_contract(
|
|
| 178 |
relation["hypothesis_order"] = None
|
| 179 |
relations.append(relation)
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
# If the interpreter omitted relations but roles make a parsimonious graph unambiguous,
|
| 182 |
# compile a safe role-derived graph rather than silently returning an empty model.
|
| 183 |
if not relations:
|
|
@@ -254,8 +548,9 @@ def build_quantitative_contract(
|
|
| 254 |
"version": STUDY_DESIGN_CONTRACT_VERSION,
|
| 255 |
"mode": "quantitative",
|
| 256 |
"source_type": _clean(source_type, 40) or "system_generated",
|
| 257 |
-
"source_topic":
|
| 258 |
-
"source_keywords":
|
|
|
|
| 259 |
"research_problem": _clean(raw.get("research_problem"), 500),
|
| 260 |
"research_questions": _keywords(raw.get("research_questions") or []),
|
| 261 |
"design_family": "cross_sectional_observational_survey",
|
|
@@ -292,6 +587,7 @@ def validate_quantitative_contract(contract: dict[str, Any]) -> list[str]:
|
|
| 292 |
ids = {x.get("construct_id") for x in constructs}
|
| 293 |
if len(ids) != len(constructs) or len(constructs) < 2:
|
| 294 |
issues.append("invalid_construct_identity")
|
|
|
|
| 295 |
for r in quant.get("relations") or []:
|
| 296 |
if not isinstance(r, dict):
|
| 297 |
issues.append("invalid_relation_record"); continue
|
|
@@ -299,6 +595,17 @@ def validate_quantitative_contract(contract: dict[str, Any]) -> list[str]:
|
|
| 299 |
issues.append("relation_references_unknown_construct")
|
| 300 |
if r.get("type") == "moderation" and r.get("moderator") not in ids:
|
| 301 |
issues.append("moderation_references_unknown_moderator")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
return list(dict.fromkeys(issues))
|
| 303 |
|
| 304 |
|
|
@@ -374,6 +681,7 @@ def build_quantitative_front_matter_contract(contract: dict[str, Any], *, versio
|
|
| 374 |
def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, Any], *, policy_version: str = "adaptive-qualitative-v1") -> dict[str, Any]:
|
| 375 |
meta = dict(metadata or {})
|
| 376 |
facts = dict(meta.get("study_facts") or {})
|
|
|
|
| 377 |
sources = []
|
| 378 |
for index, raw in enumerate(facts.get("data_sources") or [], start=1):
|
| 379 |
label = _clean(raw, 120)
|
|
@@ -405,14 +713,20 @@ def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, An
|
|
| 405 |
"has_deviant_evidence": bool(item.get("has_deviant_evidence")),
|
| 406 |
})
|
| 407 |
design = _clean(facts.get("analytic_design") or facts.get("analysis_method"), 120)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
temporal = _clean(facts.get("temporal_structure"), 160)
|
| 409 |
case_structure = _clean(facts.get("case_structure"), 160)
|
| 410 |
contract = {
|
| 411 |
"version": STUDY_DESIGN_CONTRACT_VERSION,
|
| 412 |
"mode": "qualitative",
|
| 413 |
"source_type": _clean(meta.get("source_type"),40),
|
| 414 |
-
"source_topic":
|
| 415 |
-
"source_keywords":
|
|
|
|
| 416 |
"research_problem": "",
|
| 417 |
"research_questions": [],
|
| 418 |
"design_family": design or "Iterative thematic/process analysis",
|
|
|
|
| 40 |
return out[:16]
|
| 41 |
|
| 42 |
|
| 43 |
+
|
| 44 |
+
USER_RESEARCH_INTENT_VERSION = "smartpaper-user-research-intent-v1"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _verbatim_keywords(value: Any) -> list[str]:
|
| 48 |
+
"""Preserve user keyword identity/order while trimming separator whitespace only."""
|
| 49 |
+
raw = value if isinstance(value, (list, tuple)) else re.split(r"[,;\n|]+", str(value or ""))
|
| 50 |
+
out: list[str] = []
|
| 51 |
+
seen: set[str] = set()
|
| 52 |
+
for item in raw:
|
| 53 |
+
text = re.sub(r"\s+", " ", str(item or "")).strip()
|
| 54 |
+
key = text.casefold()
|
| 55 |
+
if text and key not in seen:
|
| 56 |
+
out.append(text)
|
| 57 |
+
seen.add(key)
|
| 58 |
+
return out[:24]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
_MODERATION_CUES = (
|
| 62 |
+
r"\bmoderating\s+role\b", r"\bmoderation\b", r"\bmoderator\b", r"\bmoderates?\b",
|
| 63 |
+
r"调节作用", r"调节效应", r"调节变量", r"调节关系",
|
| 64 |
+
r"модерирующ", r"модератор", r"модерац",
|
| 65 |
+
r"модераторлық", r"модерациялық", r"модератор ретінде",
|
| 66 |
+
)
|
| 67 |
+
_MEDIATION_CUES = (
|
| 68 |
+
r"\bmediating\s+role\b", r"\bmediation\b", r"\bmediator\b", r"\bmediates?\b",
|
| 69 |
+
r"中介作用", r"中介效应", r"中介变量", r"链式中介", r"并行中介",
|
| 70 |
+
r"медиирующ", r"медиатор", r"медиац",
|
| 71 |
+
r"медиаторлық", r"медиациялық", r"медиатор ретінде",
|
| 72 |
+
)
|
| 73 |
+
_SERIAL_MEDIATION_CUES = (
|
| 74 |
+
r"\bserial\s+mediat", r"\bsequential\s+mediat", r"链式中介", r"序列中介",
|
| 75 |
+
r"последовательн.{0,16}медиац", r"тізбекті.{0,16}медиац",
|
| 76 |
+
)
|
| 77 |
+
_PARALLEL_MEDIATION_CUES = (
|
| 78 |
+
r"\bparallel\s+mediat", r"并行中介", r"параллельн.{0,16}медиац", r"параллель.{0,16}медиац",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
_QUAL_DESIGN_PATTERNS = (
|
| 82 |
+
("Grounded theory", (r"\bgrounded\s+theory\b", r"扎根理论", r"обоснованн.{0,8}теори", r"негізделген\s+теори")),
|
| 83 |
+
("Gioia-style inductive coding", (r"\bgioia\b", r"Gioia方法", r"Gioia 方法", r"джойя", r"гиоиа")),
|
| 84 |
+
("Comparative case analysis", (r"\bcomparative\s+(?:case|cases)\b", r"比较案例", r"比较个案", r"сравнительн.{0,10}(?:кейс|случа)", r"салыстырмалы.{0,10}(?:кейс|жағдай)")),
|
| 85 |
+
("Longitudinal qualitative analysis", (r"\blongitudinal\b", r"纵向(?:定性|质性)?", r"продольн", r"лонгитюд", r"бойлық")),
|
| 86 |
+
("Process analysis", (r"\bprocess\s+analysis\b", r"过程分析", r"процессн.{0,8}анализ", r"үдерістік.{0,8}талдау", r"процестік.{0,8}талдау")),
|
| 87 |
+
("Iterative thematic/process analysis", (r"\bthematic\s+analysis\b", r"主题分析", r"тематическ.{0,8}анализ", r"тақырыптық.{0,8}талдау")),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
_QUAL_SOURCE_PATTERNS = (
|
| 91 |
+
("interview", (r"\bsemi[- ]structured\s+interview", r"\binterviews?\b", r"半结构式访谈", r"半结构访谈", r"访谈", r"полуструктурированн.{0,8}интервью", r"интервью", r"жартылай құрылымданған.{0,8}сұхбат", r"сұхбат")),
|
| 92 |
+
("focus_group", (r"\bfocus\s+groups?\b", r"焦点小组", r"фокус[- ]?групп", r"фокус[- ]?топ")),
|
| 93 |
+
("observation", (r"\bobservation(?:s)?\b", r"观察", r"наблюден", r"бақылау")),
|
| 94 |
+
("document", (r"\barchival\b", r"\bdocument(?:s|ary)?\b", r"档案", r"文档", r"文件分析", r"архив", r"документ", r"мұрағат", r"құжат")),
|
| 95 |
+
("field_note", (r"\bfield\s+notes?\b", r"田野笔记", r"полев.{0,6}замет", r"далалық.{0,6}жазб")),
|
| 96 |
+
("digital_trace", (r"\bdigital\s+traces?\b", r"数字痕迹", r"цифров.{0,6}след", r"цифрлық.{0,6}із")),
|
| 97 |
+
("log", (r"\blogs?\b", r"日志", r"журнал.{0,8}лог", r"лог", r"журнал")),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _contains_any(text: str, patterns: tuple[str, ...]) -> bool:
|
| 102 |
+
return any(re.search(pattern, text, flags=re.I | re.UNICODE) for pattern in patterns)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def build_user_research_intent(topic: str, keywords: Any, *, mode: str = "") -> dict[str, Any]:
|
| 106 |
+
"""Freeze user topic/keywords as research-intent authority, separate from literature evidence."""
|
| 107 |
+
source_topic = re.sub(r"\s+", " ", str(topic or "")).strip()
|
| 108 |
+
source_keywords = _verbatim_keywords(keywords)
|
| 109 |
+
text = " ; ".join([source_topic] + source_keywords)
|
| 110 |
+
relation_types: list[str] = []
|
| 111 |
+
if _contains_any(text, _MODERATION_CUES):
|
| 112 |
+
relation_types.append("moderation")
|
| 113 |
+
if _contains_any(text, _MEDIATION_CUES):
|
| 114 |
+
relation_types.append("mediation")
|
| 115 |
+
if _contains_any(text, _SERIAL_MEDIATION_CUES):
|
| 116 |
+
relation_types.append("serial_mediation")
|
| 117 |
+
if _contains_any(text, _PARALLEL_MEDIATION_CUES):
|
| 118 |
+
relation_types.append("parallel_mediation")
|
| 119 |
+
|
| 120 |
+
qualitative_design = ""
|
| 121 |
+
for canonical, patterns in _QUAL_DESIGN_PATTERNS:
|
| 122 |
+
if _contains_any(text, patterns):
|
| 123 |
+
qualitative_design = canonical
|
| 124 |
+
break
|
| 125 |
+
source_families: list[str] = []
|
| 126 |
+
for family, patterns in _QUAL_SOURCE_PATTERNS:
|
| 127 |
+
if _contains_any(text, patterns):
|
| 128 |
+
source_families.append(family)
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"version": USER_RESEARCH_INTENT_VERSION,
|
| 132 |
+
"mode": str(mode or "").strip().casefold(),
|
| 133 |
+
"source_topic": source_topic,
|
| 134 |
+
"source_keywords": source_keywords,
|
| 135 |
+
"explicit_relation_types": relation_types,
|
| 136 |
+
"qualitative_design": qualitative_design,
|
| 137 |
+
"qualitative_data_source_families": source_families,
|
| 138 |
+
"preservation_policy": "user_topic_keywords_and_explicit_design_are_authoritative;literature_cannot_silently_redefine_scope",
|
| 139 |
+
"evidence_policy": "verified_sources_support_prior_literature;author_inference_may_develop_novel_logic_without_fake_citations",
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _cue_positions(text: str, patterns: tuple[str, ...]) -> list[int]:
|
| 144 |
+
positions: list[int] = []
|
| 145 |
+
for pattern in patterns:
|
| 146 |
+
positions.extend(match.start() for match in re.finditer(pattern, text, flags=re.I | re.UNICODE))
|
| 147 |
+
return positions
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _phrase_bound_intent_label(text: str, labels: list[str], relation: str) -> str:
|
| 152 |
+
"""Resolve an explicitly named mediator/moderator from multilingual cue grammar."""
|
| 153 |
+
for label in labels:
|
| 154 |
+
clean = re.sub(r"\s+", " ", str(label or "")).strip()
|
| 155 |
+
if not clean:
|
| 156 |
+
continue
|
| 157 |
+
esc = re.escape(clean)
|
| 158 |
+
if relation == "moderation":
|
| 159 |
+
patterns = (
|
| 160 |
+
rf"(?i)\bmoderating\s+role\s+of\s+(?:the\s+)?{esc}\b",
|
| 161 |
+
rf"(?i)\bmoderation(?:\s+effect)?\s+of\s+(?:the\s+)?{esc}\b",
|
| 162 |
+
rf"(?i)\b{esc}\b.{0,24}\b(?:as\s+(?:a\s+)?)?moderator\b",
|
| 163 |
+
rf"{esc}(?:的)?调节(?:作用|效应|变量|关系)?",
|
| 164 |
+
rf"(?i)модерирующ\w*\s+роль\s+(?:\S+\s+){{0,3}}?{esc}",
|
| 165 |
+
rf"(?i){esc}.{{0,12}}модератор ретінде",
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
patterns = (
|
| 169 |
+
rf"(?i)\bmediating\s+role\s+of\s+(?:the\s+)?{esc}\b",
|
| 170 |
+
rf"(?i)\bmediation(?:\s+effect)?\s+of\s+(?:the\s+)?{esc}\b",
|
| 171 |
+
rf"(?i)\b{esc}\b.{0,24}\b(?:as\s+(?:a\s+)?)?mediator\b",
|
| 172 |
+
rf"{esc}(?:的)?(?:链式|并行)?中介(?:作用|效应|变量)?",
|
| 173 |
+
rf"(?i)медиирующ\w*\s+роль\s+(?:\S+\s+){{0,3}}?{esc}",
|
| 174 |
+
rf"(?i){esc}.{{0,12}}медиатор ретінде",
|
| 175 |
+
)
|
| 176 |
+
if any(re.search(pattern, text, flags=re.UNICODE) for pattern in patterns):
|
| 177 |
+
return clean
|
| 178 |
+
return ""
|
| 179 |
+
|
| 180 |
+
def _closest_intent_label(text: str, labels: list[str], cue_patterns: tuple[str, ...]) -> str:
|
| 181 |
+
"""Choose the user-mentioned label nearest an explicit relation cue; never invent a label."""
|
| 182 |
+
folded = text.casefold()
|
| 183 |
+
cue_pos = _cue_positions(text, cue_patterns)
|
| 184 |
+
if not cue_pos:
|
| 185 |
+
return ""
|
| 186 |
+
scored: list[tuple[int, int, str]] = []
|
| 187 |
+
for order, label in enumerate(labels):
|
| 188 |
+
clean = re.sub(r"\s+", " ", str(label or "")).strip()
|
| 189 |
+
if not clean:
|
| 190 |
+
continue
|
| 191 |
+
start = folded.find(clean.casefold())
|
| 192 |
+
if start < 0:
|
| 193 |
+
continue
|
| 194 |
+
midpoint = start + max(1, len(clean) // 2)
|
| 195 |
+
distance = min(abs(midpoint - pos) for pos in cue_pos)
|
| 196 |
+
scored.append((distance, order, clean))
|
| 197 |
+
return min(scored)[2] if scored else ""
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _ensure_construct_role(constructs: list[dict[str, Any]], label: str, role: str) -> str:
|
| 201 |
+
if not label:
|
| 202 |
+
return ""
|
| 203 |
+
for row in constructs:
|
| 204 |
+
if str(row.get("label") or "").casefold() == label.casefold():
|
| 205 |
+
roles = list(row.get("roles") or [])
|
| 206 |
+
if role not in roles:
|
| 207 |
+
roles = [r for r in roles if not (role in {"mediator", "moderator"} and r in {"control", "covariate"})]
|
| 208 |
+
roles.append(role)
|
| 209 |
+
row["roles"] = roles
|
| 210 |
+
return str(row.get("construct_id") or "")
|
| 211 |
+
cid = f"C{len(constructs) + 1:03d}"
|
| 212 |
+
constructs.append({
|
| 213 |
+
"construct_id": cid,
|
| 214 |
+
"label": label,
|
| 215 |
+
"roles": [role],
|
| 216 |
+
"measurement_model": "reflective",
|
| 217 |
+
"measurement_allowed": True,
|
| 218 |
+
})
|
| 219 |
+
return cid
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _next_hypothesis_order(relations: list[dict[str, Any]]) -> int:
|
| 223 |
+
values = []
|
| 224 |
+
for row in relations:
|
| 225 |
+
try:
|
| 226 |
+
if row.get("hypothesis_order") is not None:
|
| 227 |
+
values.append(int(row.get("hypothesis_order")))
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
numbered_count = sum(1 for row in relations if row.get("numbered_hypothesis", True) and row.get("type") != "control_effect")
|
| 231 |
+
return max(max(values) if values else 0, numbered_count) + 1
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _append_relation(relations: list[dict[str, Any]], *, rtype: str, source: str, target: str, moderator: str = "") -> None:
|
| 235 |
+
if not source or not target or source == target:
|
| 236 |
+
return
|
| 237 |
+
for row in relations:
|
| 238 |
+
if row.get("type") != rtype or row.get("source") != source or row.get("target") != target:
|
| 239 |
+
continue
|
| 240 |
+
if rtype != "moderation" or row.get("moderator") == moderator:
|
| 241 |
+
return
|
| 242 |
+
relations.append({
|
| 243 |
+
"relation_id": f"R{len(relations) + 1:03d}",
|
| 244 |
+
"type": rtype,
|
| 245 |
+
"source": source,
|
| 246 |
+
"target": target,
|
| 247 |
+
"moderator": moderator or None,
|
| 248 |
+
"analysis_target": True,
|
| 249 |
+
"numbered_hypothesis": rtype != "control_effect",
|
| 250 |
+
"hypothesis_order": _next_hypothesis_order(relations),
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _reconcile_explicit_quantitative_intent(
|
| 255 |
+
*, topic: str, keywords: Any, constructs: list[dict[str, Any]], relations: list[dict[str, Any]], decisions: list[dict[str, Any]]
|
| 256 |
+
) -> dict[str, Any]:
|
| 257 |
+
"""Deterministically close omissions that would silently erase explicit user design intent."""
|
| 258 |
+
intent = build_user_research_intent(topic, keywords, mode="quantitative")
|
| 259 |
+
explicit = set(intent.get("explicit_relation_types") or [])
|
| 260 |
+
if not explicit:
|
| 261 |
+
return intent
|
| 262 |
+
|
| 263 |
+
intent_text = " ; ".join([intent.get("source_topic") or ""] + list(intent.get("source_keywords") or []))
|
| 264 |
+
labels = [str(row.get("label") or "") for row in constructs]
|
| 265 |
+
labels += [kw for kw in (intent.get("source_keywords") or []) if kw.casefold() not in {x.casefold() for x in labels}]
|
| 266 |
+
|
| 267 |
+
def ids_for(role: str) -> list[str]:
|
| 268 |
+
return [str(row.get("construct_id") or "") for row in constructs if role in (row.get("roles") or [])]
|
| 269 |
+
|
| 270 |
+
if "moderation" in explicit:
|
| 271 |
+
moderator_ids = ids_for("moderator")
|
| 272 |
+
# Prefer the construct named nearest the user's explicit moderation cue.
|
| 273 |
+
# A model-assigned role is advisory; the user's title/keywords are authority.
|
| 274 |
+
moderator_label = _phrase_bound_intent_label(intent_text, labels, "moderation")
|
| 275 |
+
if not moderator_label:
|
| 276 |
+
noncore_labels = [
|
| 277 |
+
str(row.get("label") or "") for row in constructs
|
| 278 |
+
if not set(row.get("roles") or []).intersection({"predictor", "mediator", "outcome"})
|
| 279 |
+
]
|
| 280 |
+
if len([label for label in noncore_labels if label]) == 1:
|
| 281 |
+
moderator_label = next(label for label in noncore_labels if label)
|
| 282 |
+
if not moderator_label:
|
| 283 |
+
moderator_label = _closest_intent_label(intent_text, labels, _MODERATION_CUES)
|
| 284 |
+
if moderator_label:
|
| 285 |
+
moderator_id = _ensure_construct_role(constructs, moderator_label, "moderator")
|
| 286 |
+
elif len(moderator_ids) == 1:
|
| 287 |
+
moderator_id = moderator_ids[0]
|
| 288 |
+
else:
|
| 289 |
+
moderator_id = ""
|
| 290 |
+
if moderator_id:
|
| 291 |
+
direct = [r for r in relations if r.get("type") == "direct_association"]
|
| 292 |
+
predictor_ids, outcome_ids = ids_for("predictor"), ids_for("outcome")
|
| 293 |
+
preferred = next((r for r in direct if r.get("source") in predictor_ids and r.get("target") in outcome_ids), None)
|
| 294 |
+
anchor = preferred or (direct[0] if direct else None)
|
| 295 |
+
source = str((anchor or {}).get("source") or (predictor_ids[0] if predictor_ids else ""))
|
| 296 |
+
target = str((anchor or {}).get("target") or (outcome_ids[0] if outcome_ids else ""))
|
| 297 |
+
before = len(relations)
|
| 298 |
+
_append_relation(relations, rtype="moderation", source=source, target=target, moderator=moderator_id)
|
| 299 |
+
if len(relations) > before:
|
| 300 |
+
decisions.append({"reason":"explicit_user_moderation_reconciled","moderator":moderator_id,"source":source,"target":target})
|
| 301 |
+
|
| 302 |
+
if "mediation" in explicit or "serial_mediation" in explicit or "parallel_mediation" in explicit:
|
| 303 |
+
mediator_ids = ids_for("mediator")
|
| 304 |
+
mediator_label = _phrase_bound_intent_label(intent_text, labels, "mediation") or _closest_intent_label(intent_text, labels, _MEDIATION_CUES)
|
| 305 |
+
if mediator_label:
|
| 306 |
+
mediator_id = _ensure_construct_role(constructs, mediator_label, "mediator")
|
| 307 |
+
mediator_ids = [mediator_id] + [mid for mid in mediator_ids if mid != mediator_id]
|
| 308 |
+
elif not mediator_ids:
|
| 309 |
+
mediator_ids = []
|
| 310 |
+
predictor_ids, outcome_ids = ids_for("predictor"), ids_for("outcome")
|
| 311 |
+
if predictor_ids and outcome_ids and mediator_ids:
|
| 312 |
+
p, o = predictor_ids[0], outcome_ids[0]
|
| 313 |
+
if "serial_mediation" in explicit and len(mediator_ids) > 1:
|
| 314 |
+
path = [p] + mediator_ids + [o]
|
| 315 |
+
for a, b in zip(path, path[1:]):
|
| 316 |
+
before = len(relations); _append_relation(relations, rtype="direct_association", source=a, target=b)
|
| 317 |
+
if len(relations) > before:
|
| 318 |
+
decisions.append({"reason":"explicit_user_serial_mediation_edge_reconciled","source":a,"target":b})
|
| 319 |
+
else:
|
| 320 |
+
for mediator_id in mediator_ids:
|
| 321 |
+
for a, b in ((p, mediator_id), (mediator_id, o)):
|
| 322 |
+
before = len(relations); _append_relation(relations, rtype="direct_association", source=a, target=b)
|
| 323 |
+
if len(relations) > before:
|
| 324 |
+
decisions.append({"reason":"explicit_user_mediation_edge_reconciled","source":a,"target":b})
|
| 325 |
+
return intent
|
| 326 |
+
|
| 327 |
def _stable_hash(payload: dict[str, Any]) -> str:
|
| 328 |
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
| 329 |
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
| 462 |
relation["hypothesis_order"] = None
|
| 463 |
relations.append(relation)
|
| 464 |
|
| 465 |
+
# R7-R2C-R5: user topic/keywords are research-intent authority. Literature
|
| 466 |
+
# availability and an incomplete model-proposed JSON may not silently erase an
|
| 467 |
+
# explicitly requested mediation/moderation design. Reconciliation is
|
| 468 |
+
# deterministic and never invents literature evidence.
|
| 469 |
+
user_intent = _reconcile_explicit_quantitative_intent(
|
| 470 |
+
topic=topic, keywords=keywords, constructs=constructs, relations=relations, decisions=rejected
|
| 471 |
+
)
|
| 472 |
+
by_id = {row["construct_id"]: row for row in constructs}
|
| 473 |
+
label_to_id = {str(row.get("label") or "").casefold(): str(row.get("construct_id") or "") for row in constructs}
|
| 474 |
+
|
| 475 |
# If the interpreter omitted relations but roles make a parsimonious graph unambiguous,
|
| 476 |
# compile a safe role-derived graph rather than silently returning an empty model.
|
| 477 |
if not relations:
|
|
|
|
| 548 |
"version": STUDY_DESIGN_CONTRACT_VERSION,
|
| 549 |
"mode": "quantitative",
|
| 550 |
"source_type": _clean(source_type, 40) or "system_generated",
|
| 551 |
+
"source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
|
| 552 |
+
"source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
|
| 553 |
+
"user_research_intent": user_intent,
|
| 554 |
"research_problem": _clean(raw.get("research_problem"), 500),
|
| 555 |
"research_questions": _keywords(raw.get("research_questions") or []),
|
| 556 |
"design_family": "cross_sectional_observational_survey",
|
|
|
|
| 587 |
ids = {x.get("construct_id") for x in constructs}
|
| 588 |
if len(ids) != len(constructs) or len(constructs) < 2:
|
| 589 |
issues.append("invalid_construct_identity")
|
| 590 |
+
relations = [r for r in (quant.get("relations") or []) if isinstance(r, dict)]
|
| 591 |
for r in quant.get("relations") or []:
|
| 592 |
if not isinstance(r, dict):
|
| 593 |
issues.append("invalid_relation_record"); continue
|
|
|
|
| 595 |
issues.append("relation_references_unknown_construct")
|
| 596 |
if r.get("type") == "moderation" and r.get("moderator") not in ids:
|
| 597 |
issues.append("moderation_references_unknown_moderator")
|
| 598 |
+
|
| 599 |
+
# Semantic completeness is part of contract validity: an internally legal graph
|
| 600 |
+
# is still invalid when it silently drops an explicit user-requested relation.
|
| 601 |
+
intent = dict(record.get("user_research_intent") or {})
|
| 602 |
+
explicit = set(intent.get("explicit_relation_types") or [])
|
| 603 |
+
if "moderation" in explicit and not any(r.get("type") == "moderation" for r in relations):
|
| 604 |
+
issues.append("explicit_user_moderation_missing")
|
| 605 |
+
if explicit.intersection({"mediation", "serial_mediation", "parallel_mediation"}) and not (quant.get("indirect_effects") or []):
|
| 606 |
+
issues.append("explicit_user_mediation_missing")
|
| 607 |
+
if str(record.get("source_topic") or "") != str(intent.get("source_topic") or record.get("source_topic") or ""):
|
| 608 |
+
issues.append("user_topic_authority_drift")
|
| 609 |
return list(dict.fromkeys(issues))
|
| 610 |
|
| 611 |
|
|
|
|
| 681 |
def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, Any], *, policy_version: str = "adaptive-qualitative-v1") -> dict[str, Any]:
|
| 682 |
meta = dict(metadata or {})
|
| 683 |
facts = dict(meta.get("study_facts") or {})
|
| 684 |
+
user_intent = build_user_research_intent(topic, keywords, mode="qualitative")
|
| 685 |
sources = []
|
| 686 |
for index, raw in enumerate(facts.get("data_sources") or [], start=1):
|
| 687 |
label = _clean(raw, 120)
|
|
|
|
| 713 |
"has_deviant_evidence": bool(item.get("has_deviant_evidence")),
|
| 714 |
})
|
| 715 |
design = _clean(facts.get("analytic_design") or facts.get("analysis_method"), 120)
|
| 716 |
+
# For system-generated qualitative evidence, explicit user method intent owns
|
| 717 |
+
# the design label. Verified user-provided material remains empirical truth
|
| 718 |
+
# and is never rewritten to satisfy a title/keyword request.
|
| 719 |
+
if str(meta.get("source_type") or "").strip().casefold() != "user_provided":
|
| 720 |
+
design = _clean(user_intent.get("qualitative_design") or design, 120)
|
| 721 |
temporal = _clean(facts.get("temporal_structure"), 160)
|
| 722 |
case_structure = _clean(facts.get("case_structure"), 160)
|
| 723 |
contract = {
|
| 724 |
"version": STUDY_DESIGN_CONTRACT_VERSION,
|
| 725 |
"mode": "qualitative",
|
| 726 |
"source_type": _clean(meta.get("source_type"),40),
|
| 727 |
+
"source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
|
| 728 |
+
"source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
|
| 729 |
+
"user_research_intent": user_intent,
|
| 730 |
"research_problem": "",
|
| 731 |
"research_questions": [],
|
| 732 |
"design_family": design or "Iterative thematic/process analysis",
|