deploy_check.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Read-only deployment diagnostics; never print credentials or provider payloads."""
  2. import json
  3. from collections import Counter
  4. from pathlib import Path
  5. def result(ok, code, **details):
  6. return {'ok': bool(ok), 'code': code, **details}
  7. def milvus(root, client_factory=None):
  8. from src.knowledge_profiles import for_agent, prepared_path
  9. from src.step2_knowledge_ingest.ingest import config
  10. settings = config(root / '.env')
  11. try:
  12. business = _milvus_snapshot(prepared_path(root, 'business'), settings, client_factory)
  13. except Exception:
  14. business = result(False, 'business_check_failed')
  15. agents = {'business': business}
  16. talent = prepared_path(root, 'talent')
  17. if talent.exists():
  18. try:
  19. agents['talent'] = _milvus_snapshot(talent, for_agent(settings, 'talent'), client_factory)
  20. except Exception:
  21. # Optional-agent errors must not hide the existing business report.
  22. agents['talent'] = result(False, 'talent_check_failed')
  23. healthy = all(row['ok'] for row in agents.values())
  24. code = business['code'] if len(agents) == 1 else 'matched' if healthy else 'knowledge_difference'
  25. return {**business, 'ok': healthy, 'code': code,
  26. 'agents': agents,
  27. 'expected_total_all_agents': sum(row.get('expected_total', 0) for row in agents.values()),
  28. 'indexed_total_all_agents': sum(row.get('indexed_total', 0) for row in agents.values())}
  29. def _milvus_snapshot(prepared, settings, client_factory):
  30. from src.knowledge_profiles import check_records
  31. from src.step2_knowledge_ingest.ingest import content_parts, load, route
  32. manifest_path = prepared / 'ingestion/manifest.json'
  33. if not manifest_path.is_file():
  34. return result(False, 'manifest_missing')
  35. manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
  36. if manifest.get('complete') is not True or not isinstance(manifest.get('databases'), dict):
  37. return result(False, 'manifest_incomplete')
  38. expected_segments = Counter()
  39. local_records = Counter()
  40. records = load(prepared)
  41. check_records(records, settings)
  42. for record in records:
  43. kind = route(record)
  44. expected_segments[kind] += len(content_parts(record))
  45. local_records[kind] += 1
  46. if dict(local_records) != manifest['counts'] or set(manifest['databases']) != set(local_records):
  47. return result(False, 'manifest_local_mismatch')
  48. if settings.get('KNOWLEDGE_AGENT') == 'talent' and set(local_records) != {'article', 'qa', 'policy'}:
  49. return result(False, 'talent_libraries_missing')
  50. if client_factory is None:
  51. from pymilvus import MilvusClient
  52. client_factory = MilvusClient
  53. client = client_factory(uri=settings['MILVUS_URI'], timeout=settings['MILVUS_TIMEOUT_SECONDS'])
  54. try:
  55. return _milvus_counts(client, settings, manifest, expected_segments)
  56. finally:
  57. close = getattr(client, 'close', None)
  58. if close is not None:
  59. close()
  60. def _milvus_counts(client, settings, manifest, expected_segments):
  61. timeout = settings['MILVUS_TIMEOUT_SECONDS']
  62. available = set(client.list_databases(timeout=timeout))
  63. libraries = {}
  64. for kind, spec in manifest['databases'].items():
  65. expected = manifest['counts'].get(kind)
  66. database = spec['database']
  67. if database != settings['MILVUS_DB_PREFIX'] + '_' + kind or database not in available:
  68. libraries[kind] = result(False, 'database_missing_or_mismatched', database=database, expected_records=expected)
  69. continue
  70. client.use_database(database, timeout=timeout)
  71. names = set(client.list_collections(timeout=timeout))
  72. index_name, content_name = spec['index_collection'], spec['content_collection']
  73. if index_name not in names or content_name not in names:
  74. libraries[kind] = result(False, 'collection_missing', database=database, expected_records=expected)
  75. continue
  76. def count(name):
  77. return client.query(name, filter='', output_fields=['count(*)'],
  78. consistency_level='Strong', timeout=timeout)[0]['count(*)']
  79. indexed, segments = count(index_name), count(content_name)
  80. expected_parts = expected_segments[kind]
  81. ok = indexed == expected and segments == expected_parts
  82. libraries[kind] = result(ok, 'matched' if ok else 'count_mismatch',
  83. database=database,
  84. expected_records=expected, indexed_records=indexed,
  85. expected_content_segments=expected_parts, content_segments=segments,
  86. index_delta=indexed - expected, content_delta=segments - expected_parts)
  87. return result(all(row['ok'] for row in libraries.values()) and bool(libraries),
  88. 'matched' if all(row['ok'] for row in libraries.values()) and libraries else 'knowledge_difference',
  89. snapshot=manifest.get('snapshot'), libraries=libraries,
  90. expected_total=sum(manifest['counts'].values()),
  91. indexed_total=sum(row.get('indexed_records', 0) for row in libraries.values()))
  92. def redis_check(root, client_factory=None):
  93. from src.step3_question_answer.context import load_context_config
  94. settings = load_context_config(root / '.env')
  95. if client_factory is None:
  96. from redis import Redis
  97. client_factory = Redis.from_url
  98. client = client_factory(settings.url, socket_connect_timeout=5, socket_timeout=5,
  99. decode_responses=False, protocol=2)
  100. try:
  101. connected = bool(client.ping())
  102. return result(connected, 'connected' if connected else 'ping_failed')
  103. finally:
  104. client.close()
  105. def dms(root, session_factory=None, company_factory=None):
  106. """Verify the five DMS columns used by the app through read-only metadata calls."""
  107. from src.step5_session_analysis.dms import DmsClient, load_dms_config
  108. from src.step3_question_answer.dms_company import DmsCompanyClient
  109. from src.dms_schema import names as schema_names
  110. session_factory = session_factory or DmsClient
  111. company_factory = company_factory or DmsCompanyClient
  112. schema_path = root / 'config/dms_schema.json'
  113. checks = {}
  114. try:
  115. session = session_factory(load_dms_config(root / '.env', schema_path=schema_path))
  116. session.connect()
  117. checks['sessions'] = result(True, 'usable', columns=3)
  118. except Exception as exc:
  119. checks['sessions'] = result(False, safe_code(exc))
  120. try:
  121. from src.dms_schema import load_schema
  122. schema = load_schema(schema_path)
  123. config = load_dms_config(root / '.env', scope_names=schema_names(schema, ('basic', 'honor')),
  124. schema_path=schema_path)
  125. company = company_factory(config)
  126. company.connect(config.schema['columns']['basic']['id'])
  127. company.connect(config.schema['columns']['honor']['id'])
  128. checks['company'] = result(True, 'usable', columns=2)
  129. except Exception as exc:
  130. checks['company'] = result(False, safe_code(exc))
  131. healthy = all(row['ok'] for row in checks.values())
  132. return result(healthy, 'usable' if healthy else 'api_failure', scopes=checks)
  133. def deepseek(root, send=None):
  134. from src.step1_knowledge_prepare.summarize import load_config, transport
  135. config = load_config(root / '.env')
  136. payload = {'model': config.model, 'messages': [{'role': 'user', 'content': 'Reply with OK.'}],
  137. 'max_tokens': 128, 'stream': False}
  138. response = (send or transport)(payload, config)
  139. choices = response.get('choices') if isinstance(response, dict) else None
  140. content = choices[0].get('message', {}).get('content') if isinstance(choices, list) and choices and isinstance(choices[0], dict) else None
  141. return result(isinstance(content, str) and bool(content.strip()),
  142. 'usable' if isinstance(content, str) and content.strip() else 'empty_model_response')
  143. def qcc(root, company_name, client=None):
  144. from src.step3_question_answer.qcc import QccClient, load_config
  145. client = client or QccClient(load_config(root / '.env'))
  146. checks = {}
  147. try:
  148. search = client.search(company_name)
  149. checks['886'] = result(True, 'usable', candidates=len(search['candidates']))
  150. except Exception as exc:
  151. checks['886'] = result(False, safe_code(exc))
  152. search = None
  153. exact = [row for row in search['candidates'] if row['Name'] == company_name] if search else []
  154. if len(exact) != 1:
  155. code = 'exact_company_not_found' if search else 'search_failed'
  156. checks['410'] = result(False, code)
  157. checks['1001'] = result(False, code)
  158. else:
  159. company = exact[0]
  160. try:
  161. client.basic(company)
  162. checks['410'] = result(True, 'usable')
  163. except Exception as exc:
  164. checks['410'] = result(False, safe_code(exc))
  165. try:
  166. honors = client.honors(company)
  167. checks['1001'] = result(honors['complete'], 'usable' if honors['complete'] else safe_code(Exception(honors['error'])),
  168. records=len(honors['records']), pages=len(honors['sources']))
  169. except Exception as exc:
  170. checks['1001'] = result(False, safe_code(exc))
  171. return result(all(row['ok'] for row in checks.values()), 'usable' if all(row['ok'] for row in checks.values()) else 'api_failure',
  172. interfaces=checks)
  173. def safe_code(exc):
  174. code = str(exc)
  175. allowed = {'provider_failure', 'qcc_auth_failed', 'invalid_qcc_configuration', 'invalid_result', 'invalid_search_result', 'invalid_candidate',
  176. 'duplicate_candidate', 'company_identity_mismatch', 'invalid_honor_result',
  177. 'invalid_honor_data', 'inconsistent_honor_result', 'inconsistent_honor_paging',
  178. 'page_limit', 'network_failure', 'invalid_json', 'response_too_large',
  179. 'invalid_dms_configuration', 'invalid_dms_login_url', 'dms_scope_denied', 'dms_redirect_refused',
  180. 'dms_transport_failed', 'dms_invalid_envelope', 'dms_permission_denied',
  181. 'dms_token_invalid', 'dms_service_failed', 'dms_auth_retry_later',
  182. 'dms_login_failed', 'dms_direct_api_requires_token', 'dms_column_mismatch',
  183. 'dms_parent_mismatch', 'dms_schema_mismatch', 'dms_write_scope_denied',
  184. 'dms_operation_denied', 'invalid_deepseek_base_url', 'missing_deepseek_configuration',
  185. 'request_timeout', 'response_incomplete', 'invalid_response_json'}
  186. return code if code in allowed or (code.startswith('http_') and code[5:].isdigit()) else 'request_failed'
  187. def run(root, company_name=None, action='check'):
  188. root = Path(root)
  189. checks = {}
  190. probes = {'milvus': lambda: milvus(root), 'redis': lambda: redis_check(root),
  191. 'dms': lambda: dms(root),
  192. 'deepseek': lambda: deepseek(root), 'qcc': lambda: qcc(root, company_name)}
  193. names = tuple(probes) if action == 'check' else (action.removeprefix('check-'),)
  194. if not names or any(name not in probes for name in names):
  195. raise ValueError('invalid_check_action')
  196. for name in names:
  197. try:
  198. checks[name] = probes[name]()
  199. except Exception as exc:
  200. checks[name] = result(False, safe_code(exc))
  201. report = {'ok': all(row['ok'] for row in checks.values()), 'checks': checks}
  202. print(json.dumps(report, ensure_ascii=False), flush=True)
  203. return 0 if report['ok'] else 1