prepare-industry-handoff.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """Build a bounded, read-only scope/quotation handoff from frozen local evidence. Never dispatch."""
  2. import argparse
  3. from datetime import datetime, timezone
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import sqlite3
  8. import sys
  9. ROOT = Path(__file__).resolve().parents[1]
  10. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  11. from industry_monitor import Monitor, load
  12. from monitoring_store import need, read_state
  13. from monitoring_dispatch import projection as budget_projection
  14. from ranking_discovery import projection as ranking_projection
  15. from monitoring_details import projection as detail_projection
  16. def digest(path):
  17. return hashlib.sha256(path.read_bytes()).hexdigest()
  18. def build(snapshot, store, catalog, out):
  19. need(not out.exists(), 'HANDOFF_OUTPUT_ALREADY_EXISTS')
  20. snapshot, store, catalog = Path(snapshot), Path(store), Path(catalog)
  21. before = digest(snapshot)
  22. data = load({'snapshot': str(snapshot)})
  23. generation = data['generation']
  24. runs = read_state(store)
  25. monitor = Monitor(data, runs)
  26. primary = monitor.selected({'platform': 'jd', 'shopId': '1000084104'})
  27. primary_ids = {p['record_id'] for p in primary}
  28. product_ids = {p['record_id'] for p in monitor.selected({})}
  29. catalog_data = json.loads(catalog.read_bytes())
  30. need(catalog_data['schema'] == 'ksf.industry-connectors.v1' and catalog_data['networkCalls'] == 0,
  31. 'READ_ONLY_CONNECTOR_CATALOG_REQUIRED')
  32. budget = budget_projection(store, generation)
  33. need(budget['paidDispatchStopped'] is True and budget['remainingDispatchMinor'] == 0,
  34. 'STOPPED_BUDGET_REQUIRED')
  35. listing_runs = [r for r in runs if r['kind'] == 'listings']
  36. shops = []
  37. for r in listing_runs:
  38. current = monitor.selected({'platform': r['platform'], 'shopId': r['scope']['shopId']})
  39. shops.append({'platform': r['platform'], 'shopId': r['scope']['shopId'], 'name': r.get('scopeName'),
  40. 'historicalProducts': len({e['productId'] for e in r['entries']}),
  41. 'historicalPages': len(r['pages']), 'historicalObservedAtRaw': r['observedAt'],
  42. 'historicalRunId': r['runId'], 'currentLibraryProducts': len(current),
  43. 'sourcePages': [{'page': p['page'], 'requestId': p['requestKey'], 'sha256': p.get('rawSha256')} for p in r['pages']],
  44. 'freshCollectionAuthorized': False})
  45. reviews_by_product = {}
  46. for r in runs:
  47. if r['kind'] == 'reviews':
  48. reviews_by_product.setdefault(r['scope']['productId'], []).append(r)
  49. from contextlib import closing
  50. with closing(sqlite3.connect((store / 'monitoring.sqlite').resolve().as_uri() + '?mode=ro', uri=True)) as c:
  51. c.execute('PRAGMA query_only=ON')
  52. charged = c.execute('SELECT product_id,page,request_id,charged_minor,business_result FROM dispatch_history ORDER BY product_id,page').fetchall()
  53. c.row_factory = sqlite3.Row
  54. from monitoring_ledger import read as read_ledger
  55. _, ledger_rows = read_ledger(c)
  56. charged_by_product = {}
  57. if ledger_rows:
  58. bound_charges = [(('jd:' if row['endpointId'].startswith('jd.') else 'taobao:') + row['params']['itemId'],
  59. row['params']['page'], row['requestId'], row['chargedMinor'], row['businessResult'])
  60. for row in ledger_rows if row['params']]
  61. else:
  62. bound_charges = charged
  63. for pid, page, request, cost, result in bound_charges:
  64. charged_by_product.setdefault(pid, []).append({'page': page, 'requestId': request,
  65. 'chargedMinor': cost, 'businessResult': result,
  66. 'automaticRetryAllowed': False})
  67. products = []
  68. for p in sorted(primary, key=lambda p: p['record_id']):
  69. pid = p['record_id']
  70. observations = []
  71. for r in reviews_by_product.get(pid, []):
  72. # This is raw interface metadata, not a promise that all pages are retrievable or billable.
  73. limits = []
  74. from monitoring_store import pointer
  75. for page in r['pages']:
  76. loc = page.get('mapping', {}).get('pageCountPointer')
  77. if page['status'] == 'success' and loc:
  78. doc = json.loads((store / 'assets' / (page['rawSha256'] + '.json')).read_bytes())
  79. limits.append(pointer(doc, loc))
  80. observations.append({'runId': r['runId'], 'observedAtRaw': r['observedAt'],
  81. 'receivedPages': [v['page'] for v in r['pages']],
  82. 'declaredPageLimits': limits, 'reportedTotal': r.get('reportedTotal'),
  83. 'reasons': r['verification']['reasons'],
  84. 'completeAccessibleScope': r['verification']['completeAccessibleScope'],
  85. 'identityConflicts': r['verification']['identityConflicts']})
  86. products.append({'productId': pid, 'title': p['title'], 'category': p.get('category'),
  87. 'libraryReviewRows': len(monitor.reviews[pid]), 'registeredReviewRuns': observations,
  88. 'settledRequests': charged_by_product.get(pid, []),
  89. 'dispatchAllowed': False,
  90. 'neededAction': 'reuse_originals_then_quote_missing_accessible_pages'})
  91. ranks = ranking_projection(store, generation, {}, product_ids)
  92. detail = detail_projection(store, generation, product_ids, {'state': 'IMAGE_ONLY'})
  93. from monitoring_policy import projection as policy_projection
  94. checked_policy = policy_projection(store, generation).get('documents', {})
  95. policy = [{'documentId': p['record_id'], 'title': p.get('title'),
  96. 'declaredStatus': p.get('declared_status'), 'declaredStatusAsOf': p.get('status_as_of'),
  97. 'sourceRecordId': p.get('source_record_id'), 'officialCheck': checked_policy.get(p['record_id']),
  98. 'reverificationNeeded': not checked_policy.get(p['record_id'], {}).get('officialMetadataVerified', False)}
  99. for p in data['entities']['documents']]
  100. result = {'schema': 'ksf.mainline-handoff.v1', 'createdAt': datetime.now(timezone.utc).isoformat(),
  101. 'generation': generation, 'snapshotSha256': before, 'catalogSha256': digest(catalog),
  102. 'dispatchAllowed': False, 'paidCalls': 0, 'researchWrites': 0, 'budget': budget,
  103. 'primaryShop': {'platform': 'jd', 'shopId': '1000084104', 'products': len(primary),
  104. **{k: monitor.review_coverage({'platform': 'jd', 'shopId': '1000084104'})[k]
  105. for k in ('reviewRows', 'uniqueReadableTexts')}},
  106. 'shops': shops, 'primaryProducts': products,
  107. 'chargedHistory': {'identifiedRows': budget['identifiedSettledRequests'],
  108. 'parameterBoundRows': budget.get('parameterBoundSettledRequests', len(charged)),
  109. 'queueUnionRows': len(charged), 'priorAggregateRows': budget['aggregateOnlyRows'],
  110. 'all154IdentitiesVerified': budget['fullHistoricalIdentitiesVerified'],
  111. 'historyOutsidePrimaryShop': sorted(set(charged_by_product) - primary_ids)},
  112. 'rankingIdentities': ranks, 'detailResponseCoverage': detail['coverage'],
  113. 'policyDocumentsForStatusReview': policy,
  114. 'connectors': catalog_data['existingConnectors'],
  115. 'rankingConnectors': catalog_data['jdTmallRankingCandidates'],
  116. 'quotation': {'status': 'NOT_OBTAINED', 'currency': 'CNY', 'authorizedNewMinor': 0,
  117. 'reviewsHistoricalUnitMinor': 30, 'historicalRateIsCurrentQuote': False,
  118. 'estimatedTotalMinor': None,
  119. 'reasons': ['current_unit_prices_not_confirmed', 'accessible_page_limits_not_confirmed',
  120. 'ranking_endpoint_not_confirmed'] +
  121. (['remaining_15_request_parameter_bindings_incomplete'] if ledger_rows else
  122. ['prior_78_request_identities_incomplete']),
  123. 'requiredBreakdown': ['already_paid_original_recovery', 'current_8shop_listings',
  124. 'primary_shop_missing_accessible_review_pages',
  125. 'detail_claim_text_and_images', 'ranking_top100_or_top400',
  126. 'monitoring_cost_per_day_and_stop_limit']}}
  127. need(digest(snapshot) == before, 'SNAPSHOT_CHANGED')
  128. out.mkdir(parents=True)
  129. (out / 'collector-scope.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  130. summary = {'ok': True, 'generation': generation, 'snapshotSha256': before, 'shops': len(shops),
  131. 'primaryProducts': len(products), 'identifiedChargedRequests': budget['identifiedSettledRequests'],
  132. 'parameterBoundChargedRequests': budget.get('parameterBoundSettledRequests', len(charged)),
  133. 'queueUnionRequests': len(charged),
  134. 'reviewProductsWithRegisteredResponse': sum(bool(p['registeredReviewRuns']) for p in products),
  135. 'reviewProductsWithoutRegisteredResponse': sum(not p['registeredReviewRuns'] for p in products),
  136. 'rankingIdentities': ranks['knownRankIds'], 'estimatedTotalMinor': None,
  137. 'newBudgetAuthorizedMinor': 0, 'paidCalls': 0, 'researchWrites': 0}
  138. (out / 'scope-receipt.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  139. return summary
  140. if __name__ == '__main__':
  141. p = argparse.ArgumentParser(description=__doc__)
  142. p.add_argument('--snapshot', type=Path, required=True)
  143. p.add_argument('--store', type=Path, required=True)
  144. p.add_argument('--catalog', type=Path, required=True)
  145. p.add_argument('--out', type=Path, required=True)
  146. args = p.parse_args()
  147. print(json.dumps(build(args.snapshot, args.store, args.catalog, args.out), ensure_ascii=False))