A5-R3-R7-R2C-R5 user research intent authority scoped deployment

#120
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
- """Normalize model-generated keywords without replacing semantic context."""
 
 
 
 
 
 
 
 
 
1800
  values = []
1801
  seen = set()
1802
- for item in candidate_keywords or []:
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
- constructs = meta.get('constructs') if isinstance(meta.get('constructs'), dict) else {}
1895
- for role in ('predictor', 'mediator', 'outcome'):
1896
- add(constructs.get(role), f'quantitative_{role}', 'STRICT_CONSTRUCT')
 
 
 
 
 
 
 
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
- # User keywords remain planning seeds, not canonical identities. Use
1915
- # them only as a fallback when frozen empirical/qualitative metadata and
1916
- # outline concepts provide no concept labels at all.
1917
- if not items:
1918
- for seed in re.split(r'[,;\n|]+', str(keywords or '')):
1919
- seed = re.sub(r'\s+', ' ', seed).strip()
1920
- if 1 <= len(seed.split()) <= 6:
1921
- add(seed, 'keyword_fallback', 'FLEXIBLE_TERM')
 
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 {'version': 'concept-ledger-v2', 'items': items}
 
 
 
 
 
 
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
- en_topic = str(plan.get("en_topic", topic))
 
 
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
- Return 4-6 concise academic keywords. User keywords are seeds, not an authoritative final list.
1968
  TOPIC: {topic}
1969
- USER KEYWORD SEEDS: {keywords}
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
- Return 4-6 concise final academic keywords derived from the completed manuscript. User keyword seeds are advisory only.
2004
  TOPIC: {topic}
2005
- USER KEYWORD SEEDS: {keywords}
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 STUDY_DESIGN_CONTRACT_VERSION, build_qualitative_contract
 
 
 
 
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
- 1. Use 2-8 distinct constructs only when the topic actually requires them; do NOT force a mediator or moderator.
504
- 2. Preserve explicit mediation, moderation, serial/parallel mediation, multiple antecedents/outcomes, and controls when the topic clearly requests them.
 
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 fell back safely: %s", issues[:3])
530
- return build_quantitative_contract(topic, keywords, fallback_candidate)
 
 
 
 
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": _clean(topic, 500),
258
- "source_keywords": _keywords(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": _clean(topic,500),
415
- "source_keywords": _keywords(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",