Files
colleague-skill/tools/share_life.py
2026-04-09 16:39:51 +08:00

403 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
share_life.py — /share-life orchestrator
Reads the current colleague's persona.md + meta.json, deeply parses
the persona layers to extract visual cues, hobbies, and environment hints,
then builds a rich NanoBanana image prompt.
Usage:
python tools/share_life.py \
--slug "example_tianyi" \
--chat-id "123456789" \
[--scene "傍晚下班路上"] # optional override
"""
import argparse
import json
import random
import re
import sys
from pathlib import Path
COLLEAGUES_DIR = Path(__file__).parent.parent / "colleagues"
# ─── Persona Parser ───────────────────────────────────────────────────────────
class PersonaParser:
"""
Parses colleague persona.md into structured visual signals for image generation.
Extracts from:
Layer 0 — core personality traits → character adjectives
Layer 1 — identity paragraph → role description, MBTI energy
Layer 2 — expression style, emoji usage → visual mood/energy
Layer 3 — priorities → what they look like when focused
Layer 4 — interpersonal behavior → typical social scenes
Layer 5 — excited topics + dislikes → hobbies, environments, activities
"""
def __init__(self, persona_text: str, meta: dict):
self.text = persona_text
self.meta = meta
self._layers = self._split_layers()
def _split_layers(self) -> dict:
"""Split persona.md into layer blocks by heading."""
layers = {}
current = "intro"
buffer = []
for line in self.text.split("\n"):
m = re.match(r"##\s+Layer\s+(\d+)", line)
if m:
layers[current] = "\n".join(buffer)
current = f"layer{m.group(1)}"
buffer = []
else:
buffer.append(line)
layers[current] = "\n".join(buffer)
return layers
def _bullets(self, layer_key: str) -> list[str]:
"""Extract bullet point items from a layer."""
text = self._layers.get(layer_key, "")
return [
re.sub(r"^[-•]\s*", "", line).strip()
for line in text.split("\n")
if re.match(r"^\s*[-•]", line) and len(line.strip()) > 3
]
def _find_section(self, layer_key: str, section_name: str) -> str:
"""Extract content under a ### subsection within a layer."""
text = self._layers.get(layer_key, "")
pattern = rf"###\s+{re.escape(section_name)}.*?\n(.*?)(?=###|\Z)"
m = re.search(pattern, text, re.DOTALL)
return m.group(1).strip() if m else ""
# ── Public accessors ──────────────────────────────────────────────────────
@property
def name(self) -> str:
return self.meta.get("name", "同事").split("")[0].strip()
@property
def role(self) -> str:
return self.meta.get("profile", {}).get("role", "")
@property
def mbti(self) -> str:
# colleague-skill: profile.mbti (string)
# ex-skill: mbti.type (nested object)
v = self.meta.get("profile", {}).get("mbti") or self.meta.get("mbti")
if isinstance(v, dict):
return v.get("type", "")
return v or ""
@property
def mbti_dominant(self) -> str:
"""Ex-skill stores dominant function explicitly."""
v = self.meta.get("mbti")
if isinstance(v, dict):
return v.get("dominant", "")
return ""
@property
def impression(self) -> str:
return self.meta.get("impression", "")
@property
def personality_tags(self) -> list[str]:
tags = self.meta.get("tags", {})
# colleague-skill: tags.personality
if "personality" in tags:
return tags["personality"]
# ex-skill: tags.rel_traits + tags.attachment
return tags.get("rel_traits", []) + tags.get("attachment", [])
@property
def core_traits(self) -> list[str]:
"""From Layer 0: short adjectives describing how they act."""
bullets = self._bullets("layer0")
# Shorten to key phrases
traits = []
for b in bullets[:5]:
# Extract the core part before the em-dash or comma
short = re.split(r"[——,,。]", b)[0].strip()
if 3 < len(short) < 20:
traits.append(short)
return traits
@property
def hobbies(self) -> list[str]:
"""
From Layer 5: topics they get excited about → activities for image scenes.
colleague-skill: explicit "你会兴奋的话题" section.
ex-skill: inferred from MBTI Se/Ne, emoji clues in Layer 2, and Layer 1 keywords.
"""
layer5 = self._layers.get("layer5", "")
# colleague-skill format
m = re.search(r"你会兴奋的话题[:]\s*\n(.*?)(?=\n你会|$)", layer5, re.DOTALL)
if m:
hobbies = []
for line in m.group(1).split("\n"):
line = re.sub(r"^[-•]\s*", "", line).strip()
if line and len(line) > 2:
hobbies.append(line)
return hobbies
# ex-skill: infer from MBTI + emoji + Layer 1 keywords
return self._infer_hobbies_from_context()
def _infer_hobbies_from_context(self) -> list[str]:
"""Infer likely activities from MBTI dominant function and emoji usage."""
hints = []
# MBTI Se (ISFP, ISTP, ESFP, ESTP) → sensory, aesthetic, present-moment
if "Se" in self.mbti_dominant or self.mbti in ("ISFP", "ISTP", "ESFP", "ESTP"):
hints += ["walks in the city", "photography or visual arts", "listening to music"]
# MBTI Ne (ENFP, ENTP, INFP, INTP) → ideas, reading, exploring
if "Ne" in self.mbti_dominant or self.mbti in ("ENFP", "ENTP", "INFP", "INTP"):
hints += ["reading a book", "sketching or journaling", "browsing ideas"]
# Emoji clues from Layer 2
layer2_text = self._layers.get("layer2", "")
if "🐱" in layer2_text or "" in layer2_text:
hints.insert(0, "spending time with a cat")
if "📷" in layer2_text or "摄影" in layer2_text:
hints.insert(0, "taking photos on a quiet street")
if "🎵" in layer2_text or "音乐" in layer2_text:
hints.insert(0, "listening to music with headphones")
if "" in layer2_text or "咖啡" in layer2_text:
hints.insert(0, "at a café with a book or phone")
# Enneagram 3 → achievement, polished image
mbti_meta = self.meta.get("mbti", {})
if isinstance(mbti_meta, dict) and "3" in mbti_meta.get("enneagram", ""):
hints += ["working on a personal project, focused and driven"]
return hints[:4]
@property
def typical_scenes(self) -> list[str]:
"""From Layer 4 + Layer 3: what do they typically do?"""
scenes = []
# Extract "典型场景" blocks from Layer 4
layer4 = self._layers.get("layer4", "")
for chunk in re.findall(r"典型场景:\n(.*?)(?=###|\Z)", layer4, re.DOTALL):
for line in chunk.split("\n"):
m = re.match(r"\s*[-•]\s*(.*)", line)
if m:
scenes.append(m.group(1).strip())
return scenes[:4]
@property
def emoji_vibe(self) -> str:
"""From Layer 2: emoji usage hints at energy level."""
style_text = self._find_section("layer2", "说话方式")
if "不多" in style_text or "偶尔" in style_text:
return "minimal emoji energy, calm and focused"
elif "活跃" in style_text or "兴奋" in style_text:
return "expressive, warm, animated"
return "balanced, approachable"
@property
def visual_keywords(self) -> list[str]:
"""Synthesize visual adjectives from all layers for image prompt."""
words = []
# From personality tags
tag_map = {
# colleague-skill tags
"靠谱": "reliable, steady gaze",
"代码规范": "detail-oriented, neat workspace",
"热心": "warm expression, open posture",
"健谈": "conversational, mid-gesture",
"游戏爱好者": "gaming setup, controller nearby",
"严谨": "focused, methodical",
"直接": "confident, direct eye contact",
"温柔": "gentle, soft expression",
"细腻": "thoughtful, quiet energy",
"务实": "pragmatic, grounded",
# ex-skill rel_traits
"话少但在乎": "quiet presence, observant eyes",
"高冷装": "composed exterior, soft interior hinted",
"行动派": "in motion, purposeful stance",
"需要空间": "solitary moment, breathing room",
"道歉困难户": "proud posture, slightly averted gaze",
# ex-skill attachment
"回避型": "comfortable alone, self-contained energy",
}
for tag in self.personality_tags:
if tag in tag_map:
words.append(tag_map[tag])
# MBTI energy
if self.mbti:
if self.mbti.startswith("E"):
words.append("outward energy, sociable")
elif self.mbti.startswith("I"):
words.append("inward focus, contemplative")
if "N" in self.mbti:
words.append("imaginative, thoughtful")
if "F" in self.mbti:
words.append("emotionally present")
if "T" in self.mbti:
words.append("analytical, precise")
return words
# ─── Scene Generator ──────────────────────────────────────────────────────────
def _hobby_to_scene(hobby: str) -> str | None:
"""Map a hobby/interest string to an image scene description."""
hobby_lower = hobby.lower()
scene_map = [
(["cat", "", "小猫"],
"at home on a quiet afternoon, a cat curled up nearby, soft warm light"),
(["杀戮尖塔", "slay the spire", "roguelike", "roguelite", "游戏"],
"playing a roguelike game late at night, monitor glow, snacks on desk, fully absorbed"),
(["模型安全", "对齐", "alignment", "安全", "红队"],
"deep in research at a desk, papers and diagrams, focused expression"),
(["音乐", "吉他", "钢琴", "耳机"],
"listening to music with headphones, eyes closed, soft evening light"),
(["摄影", "相机"],
"out with a camera, capturing a quiet street scene"),
(["跑步", "健身", "运动"],
"early morning run, city streets, dawn light"),
(["读书", "看书"],
"reading a book in a cozy corner, warm lamp light"),
(["咖啡"],
"at a café, notebook open, afternoon light through window"),
(["动漫", "二次元", "漫画"],
"reading manga or watching anime, cozy bedroom setting"),
(["烹饪", "做饭"],
"cooking at home, kitchen warm light, relaxed and focused"),
]
for keywords, scene in scene_map:
if any(k in hobby_lower for k in keywords):
return scene
return None
def pick_scene(parser: PersonaParser, scene_override: str | None = None) -> str:
"""Pick the best scene for this persona."""
if scene_override:
return scene_override
# Try hobby-based scenes first (most specific)
hobby_scenes = []
for hobby in parser.hobbies:
s = _hobby_to_scene(hobby)
if s:
hobby_scenes.append(s)
# Fallback: generic scenes based on role/energy
role_scenes = [
"commute home on a subway, earphones in, watching the city scroll by",
"lunch break at a quiet corner, lost in thought over a bowl of noodles",
"evening walk in a neighborhood, streetlights just turning on, hands in pockets",
"sitting by a window with a drink, watching the rain outside",
]
candidates = hobby_scenes + role_scenes
return random.choice(candidates) if candidates else role_scenes[0]
# ─── Prompt Builder ───────────────────────────────────────────────────────────
def build_image_prompt(parser: PersonaParser, scene: str) -> str:
"""
Build a rich image prompt focused on the person as a character —
not their work role, just who they are as a human being.
"""
# Lead with name + impression (personality anchor), skip job title
character_desc = parser.name
if parser.impression:
character_desc += f". The kind of person you would describe as: \"{parser.impression}\""
visual_kw = ", ".join(parser.visual_keywords[:4]) if parser.visual_keywords else ""
vibe = parser.emoji_vibe
# Randomly pick a sharing format for variety
sharing_formats = [
"casual selfie, person holding phone slightly above, natural expression, not posed",
"mirror selfie, holding phone up in front of mirror, relaxed outfit, natural background",
"photo of something they love — an object, a view, a small moment — no face needed",
"candid shot of them in the scene, like a friend took it without them noticing",
"POV shot of what they're looking at right now — their perspective",
]
sharing_style = random.choice(sharing_formats)
prompt_parts = [
f"Photo of {character_desc}.",
f"Scene: {scene}.",
f"Format: {sharing_style}.",
f"Energy: {vibe}.",
]
if visual_kw:
prompt_parts.append(f"Visual character cues: {visual_kw}.")
prompt_parts.append(
"Style: photorealistic, feels like a casual social media post, "
"everyday life snapshot, shot on phone camera, natural imperfect lighting, "
"authentic and unfiltered, no studio look, no text overlays, no watermarks."
)
return " ".join(prompt_parts)
# ─── Loader ───────────────────────────────────────────────────────────────────
def load_colleague(slug: str) -> tuple[PersonaParser, dict]:
base = COLLEAGUES_DIR / slug
if not base.exists():
raise FileNotFoundError(f"Colleague '{slug}' not found in {COLLEAGUES_DIR}")
meta = json.loads((base / "meta.json").read_text())
persona_text = (base / "persona.md").read_text()
parser = PersonaParser(persona_text, meta)
return parser, meta
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--slug", required=True)
parser.add_argument("--chat-id", required=True)
parser.add_argument("--scene", default=None)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
persona, meta = load_colleague(args.slug)
except FileNotFoundError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
scene = pick_scene(persona, scene_override=args.scene)
prompt = build_image_prompt(persona, scene)
if args.dry_run:
print(json.dumps({
"name": persona.name,
"hobbies": persona.hobbies,
"core_traits": persona.core_traits,
"visual_keywords": persona.visual_keywords,
"scene": scene,
"prompt": prompt,
}, ensure_ascii=False, indent=2))
return
sys.path.insert(0, str(Path(__file__).parent))
from image_generator import cmd_generate
result = cmd_generate(prompt=prompt, slug=args.slug, chat_id=args.chat_id)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()