import hashlib import json import math import re from collections import Counter from datetime import datetime SIGNALS = {'repeat': '反复提问', 'risk': '风险/事故线索', 'rework': '重复踩坑线索', 'future': '后续复用线索'} STRATEGIES = ['正式资产', '轻量记录', '专家带教', '暂不投入'] QUESTION = re.compile(r'[??]|怎么|如何|为什么|是否|求助|请教|谁知道|有没有|怎么办|不清楚|不会|帮忙|报错|失败|异常|出错') RISK = re.compile(r'事故|故障|损失|误判|错误|风险|差点|出错|报错|失败|异常|生产|回滚') REWORK = re.compile(r'返工|重做|重来|又.{0,8}(错|失败|出问题)|再次|上次.{0,8}(错|问题)|重复踩') FUTURE = re.compile(r'下次|明年|下个月|后续|接下来|以后|还要|将要|计划|即将') STOP = re.compile(r'请问|请教|大家|这个|那个|一下|怎么|如何|什么|有没有|能不能|是不是|谢谢|麻烦|帮忙|知道|可以|还是') def terms(text): text = STOP.sub('', text.lower()) chunks = re.findall(r'[a-z0-9_]{2,}|[\u4e00-\u9fff]+', text) result = set() for chunk in chunks: if re.fullmatch('[\u4e00-\u9fff]+', chunk): result.update(chunk[i:i+2] for i in range(len(chunk)-1)) else: result.add(chunk) return result def focus_terms(text): # A question's qualifiers often follow the question mark; keep its core stable. return terms(re.split(r'[??。!!\n]', text, maxsplit=1)[0]) def is_question(text): return bool(QUESTION.search(re.sub(r'https?://\S+', '', text))) def similarity(a, b): return len(a & b) / math.sqrt(len(a) * len(b)) if a and b else 0 def extract_events(messages): """Question-led time windows, with bounded context and explicit uncertainty.""" by_conv = {} for msg in messages: by_conv.setdefault(msg['conversation_id'], []).append(msg) events = [] for cid, rows in by_conv.items(): current = None for msg in rows: moment = datetime.fromisoformat(msg['timestamp'].replace('Z', '+00:00')) interesting = bool(is_question(msg['content']) or RISK.search(msg['content']) or REWORK.search(msg['content']) or FUTURE.search(msg['content'])) gap = (moment - current['_last']).total_seconds() if current else float('inf') related = current and (not interesting or msg['quote'] or similarity(focus_terms(msg['content']), current['_terms']) >= .24) if current and gap <= 1800 and len(current['messages']) < 30 and related: current['messages'].append(msg) current['_last'] = moment elif interesting: current = dict(id=f"E{msg['id']}", conversation_id=cid, conversation=msg['conversation'], title=msg['content'][:100], messages=[msg], _last=moment, _terms=focus_terms(msg['content'])) events.append(current) else: current = None for event in events: del event['_last'] del event['_terms'] return sorted(events, key=lambda e: e['messages'][0]['timestamp']) def make_card(events, title=None): msgs = [m for e in events for m in e['messages']] text = '\n'.join(m['content'] for m in msgs) signals = [] if len(events) > 1: signals.append('repeat') for key, pattern in [('risk', RISK), ('rework', REWORK), ('future', FUTURE)]: if pattern.search(text): signals.append(key) responders = sorted(set(m['sender'] for e in events for m in e['messages'][1:] if m['message_type'] == 'text' and not is_question(m['content']) and m['sender_id'] != e['messages'][0]['sender_id'] and len(m['content']) >= 12)) gaps = sorted(set(m['message_type'] for m in msgs if m['message_type'] != 'text')) return dict(id=hashlib.sha256('|'.join(e['id'] for e in events).encode()).hexdigest()[:16], title=title or events[0]['title'], summary='基于时间窗口和文本相似度的候选主题,需人工核对。', signals=signals, event_ids=[e['id'] for e in events], evidence_ids=sorted(set(m['id'] for m in msgs)), event_count=len(events), conversations=sorted(set(m['conversation'] for m in msgs)), participants=sorted(set(m['sender'] for m in msgs)), responders=responders, first=min(m['timestamp'] for m in msgs), last=max(m['timestamp'] for m in msgs), status='待核实', strategy='正式资产' if len(events) >= 3 or 'risk' in signals else '轻量记录', priority=min(100, 15*len(events) + 20*('risk' in signals) + 15*('rework' in signals) + 10*('future' in signals)), answer='尚未验证。候选答复请查看原文,不能据此认定问题已经解决。', boundaries='自动按 30 分钟窗口收集上下文;可能包含交错话题或漏掉延迟回复。' + (' 包含非文字消息:' + '、'.join(gaps) if gaps else ''), reviewed=False, notes='') def local_cards(events, cross=True): groups, signatures, inverted = [], [], {} for event in events: sig = focus_terms(event['title']) candidates = set() for term in sig: candidates.update(inverted.get(term, [])) best, score = None, .42 for idx in candidates: if not cross and groups[idx][0]['conversation_id'] != event['conversation_id']: continue value = similarity(sig, signatures[idx]) if value > score: best, score = idx, value if best is None: best = len(groups) groups.append([event]) signatures.append(sig) for term in sig: inverted.setdefault(term, []).append(best) else: groups[best].append(event) return sorted([make_card(group) for group in groups], key=lambda c: (-c['priority'], -c['event_count'])) class Redactor: def __init__(self, all_messages, secret, custom=None): self.secret = secret self.pairs = {} for msg in all_messages: for kind, fields in [('人员', ['sender', 'sender_id']), ('会话', ['conversation', 'conversation_id'])]: values = [msg.get(field, '') for field in fields] canonical = next((v for v in reversed(values) if v), '') alias = self.alias(kind, canonical) for value in values: if value: self.pairs[value] = alias for value in custom or []: if value.strip(): self.pairs[value.strip()] = self.alias('敏感词', value.strip()) self.pattern = re.compile('|'.join(re.escape(v) for v in sorted(self.pairs, key=len, reverse=True))) if self.pairs else None def alias(self, kind, value): # Salted aliases cannot be guessed from a public hash of a person's name. import hmac return '[' + kind + '_' + hmac.new(self.secret.encode(), value.encode(), hashlib.sha256).hexdigest()[:10] + ']' def redact(self, text): text = str(text) if self.pattern: text = self.pattern.sub(lambda m: self.pairs[m[0]], text) patterns = [ ('凭据', r'(?i)\b(?:sk-[a-z0-9_-]{12,}|bearer\s+[a-z0-9._-]{10,})'), ('邮箱', r'[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}'), ('链接', r'(?i)https?://[^\s<>\]\))]+'), ('身份证', r'(? 48000: raise ValueError('某个讨论事件超过 48000 字符,请缩小时间范围或在本地拆分后重试;工具不会截断发送') if current and (size + length > 48000 or len(current) >= 30): batches.append(current) current, size = [], 0 current.append(item) size += length if current: batches.append(current) return [{'cross_conversation': bool(cross), 'events': batch} for batch in batches] def validate_model_cards(output, events, cross=True): if not isinstance(output, dict) or not isinstance(output.get('cards'), list): raise ValueError('模型没有返回 cards 数组') by_id = {e['id']: e for e in events} seen, cards = set(), [] for raw in output['cards']: if not isinstance(raw, dict): raise ValueError('模型卡片格式错误') ids = raw.get('event_ids') if not isinstance(ids, list) or not ids or any(not isinstance(i, str) or i not in by_id or i in seen for i in ids) or len(ids) != len(set(ids)): raise ValueError('模型引用了未知、重复或为空的事件') group = [by_id[i] for i in ids] if not cross and len({e['conversation_id'] for e in group}) > 1: raise ValueError('模型违反了按会话分析的限制') valid = {m['id'] for e in group for m in e['messages']} evidence = raw.get('evidence_ids') if not isinstance(evidence, list) or not evidence or any(type(i) is not int or i not in valid for i in evidence): raise ValueError('模型引用了无效的原文证据') card = make_card(group) for key in ('title', 'summary', 'answer', 'boundaries'): if not isinstance(raw.get(key), str) or not raw[key].strip(): raise ValueError('模型缺少字段:' + key) card[key] = raw[key][:6000] if raw.get('strategy') not in STRATEGIES: raise ValueError('模型给出了无效的处理策略') card['strategy'] = raw['strategy'] allowed = ('已有经验待验证', '答案冲突', '未见明确答案', '待核实') card['status'] = raw.get('status') if raw.get('status') in allowed else '待核实' signals = raw.get('signals', []) if not isinstance(signals, list) or any(s not in SIGNALS for s in signals): raise ValueError('模型信号格式错误') card['signals'] = sorted(set(signals) - ({'repeat'} if len(group) < 2 else set())) card['evidence_ids'] = sorted(set(evidence)) # Local responders remain only candidates, never AI-certified experts. card['boundaries'] += ' 模型结论待人工审核;分批分析可能漏掉批次间的相似问题。' cards.append(card) seen.update(ids) if seen != set(by_id): raise ValueError('模型漏掉了部分事件,本次结果未保存') return cards