Merge pull request #737: normalize generated profile fields
Establish one canonical profile boundary before Twitter and Reddit serialization.
This commit is contained in:
commit
4064b68abf
|
|
@ -27,6 +27,51 @@ from .zep_entity_reader import EntityNode, ZepEntityReader
|
||||||
logger = get_logger('mirofish.oasis_profile')
|
logger = get_logger('mirofish.oasis_profile')
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_to_str(value: Any) -> str:
|
||||||
|
"""Coerce a value to a plain string.
|
||||||
|
|
||||||
|
Handles dict, list, and other non-string types that may be returned
|
||||||
|
by LLM JSON parsing.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ('text', 'value', 'description', 'content', 'summary', 'name'):
|
||||||
|
if key in value:
|
||||||
|
candidate = _coerce_to_str(value[key])
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
str_items = [_coerce_to_str(item) for item in value]
|
||||||
|
str_items = [item for item in str_items if item]
|
||||||
|
return ', '.join(str_items)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_to_str_list(value: Any) -> List[str]:
|
||||||
|
"""Coerce a value to a list of strings.
|
||||||
|
|
||||||
|
Handles nested structures that may be returned by LLM JSON parsing.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
result: List[str] = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, (list, tuple)):
|
||||||
|
result.extend(_coerce_to_str_list(item))
|
||||||
|
else:
|
||||||
|
text = _coerce_to_str(item)
|
||||||
|
if text:
|
||||||
|
result.append(text)
|
||||||
|
return result
|
||||||
|
text = _coerce_to_str(value)
|
||||||
|
return [text] if text else []
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OasisAgentProfile:
|
class OasisAgentProfile:
|
||||||
"""OASIS Agent Profile数据结构"""
|
"""OASIS Agent Profile数据结构"""
|
||||||
|
|
@ -59,6 +104,18 @@ class OasisAgentProfile:
|
||||||
|
|
||||||
created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
|
created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Normalize structured LLM fields once at the profile boundary."""
|
||||||
|
self.bio = _coerce_to_str(self.bio) or self.name
|
||||||
|
self.persona = _coerce_to_str(self.persona) or (
|
||||||
|
f"{self.name} is a participant in social discussions."
|
||||||
|
)
|
||||||
|
self.country = _coerce_to_str(self.country) or None
|
||||||
|
self.profession = _coerce_to_str(self.profession) or None
|
||||||
|
self.gender = _coerce_to_str(self.gender) or None
|
||||||
|
self.mbti = _coerce_to_str(self.mbti) or None
|
||||||
|
self.interested_topics = _coerce_to_str_list(self.interested_topics)
|
||||||
|
|
||||||
def to_reddit_format(self) -> Dict[str, Any]:
|
def to_reddit_format(self) -> Dict[str, Any]:
|
||||||
"""转换为Reddit平台格式"""
|
"""转换为Reddit平台格式"""
|
||||||
profile = {
|
profile = {
|
||||||
|
|
@ -1170,8 +1227,8 @@ class OasisProfileGenerator:
|
||||||
"user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id
|
"user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id
|
||||||
"username": profile.user_name,
|
"username": profile.user_name,
|
||||||
"name": profile.name,
|
"name": profile.name,
|
||||||
"bio": profile.bio[:150] if profile.bio else f"{profile.name}",
|
"bio": profile.bio[:150],
|
||||||
"persona": profile.persona or f"{profile.name} is a participant in social discussions.",
|
"persona": profile.persona,
|
||||||
"karma": profile.karma if profile.karma else 1000,
|
"karma": profile.karma if profile.karma else 1000,
|
||||||
"created_at": profile.created_at,
|
"created_at": profile.created_at,
|
||||||
# OASIS必需字段 - 确保都有默认值
|
# OASIS必需字段 - 确保都有默认值
|
||||||
|
|
@ -1204,4 +1261,3 @@ class OasisProfileGenerator:
|
||||||
"""[已废弃] 请使用 save_profiles() 方法"""
|
"""[已废弃] 请使用 save_profiles() 方法"""
|
||||||
logger.warning("save_profiles_to_json已废弃,请使用save_profiles方法")
|
logger.warning("save_profiles_to_json已废弃,请使用save_profiles方法")
|
||||||
self.save_profiles(profiles, file_path, platform)
|
self.save_profiles(profiles, file_path, platform)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.services.oasis_profile_generator import (
|
||||||
|
OasisAgentProfile,
|
||||||
|
OasisProfileGenerator,
|
||||||
|
_coerce_to_str,
|
||||||
|
_coerce_to_str_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_coercion_handles_none_nested_objects_and_lists():
|
||||||
|
assert _coerce_to_str(None) == ""
|
||||||
|
assert _coerce_to_str({"text": {"value": "中文"}}) == "中文"
|
||||||
|
assert _coerce_to_str([None, {"description": "alpha"}, ["beta"]]) == "alpha, beta"
|
||||||
|
assert _coerce_to_str({"unexpected": None}) == '{"unexpected": null}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_coercion_flattens_nested_values_and_drops_missing_items():
|
||||||
|
assert _coerce_to_str_list(
|
||||||
|
["AI", ["policy", None], {"name": "society"}, 4]
|
||||||
|
) == ["AI", "policy", "society", "4"]
|
||||||
|
assert _coerce_to_str_list(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_construction_is_the_single_normalization_boundary():
|
||||||
|
profile = OasisAgentProfile(
|
||||||
|
user_id=1,
|
||||||
|
user_name="agent",
|
||||||
|
name="Agent Name",
|
||||||
|
bio=None,
|
||||||
|
persona={"text": {"value": "详细人设"}},
|
||||||
|
gender=["female"],
|
||||||
|
mbti={"value": "INTJ"},
|
||||||
|
country={"name": "中国"},
|
||||||
|
profession={"description": "研究员"},
|
||||||
|
interested_topics=["AI", ["政策", None], {"name": "社会"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert profile.bio == "Agent Name"
|
||||||
|
assert profile.persona == "详细人设"
|
||||||
|
assert profile.gender == "female"
|
||||||
|
assert profile.mbti == "INTJ"
|
||||||
|
assert profile.country == "中国"
|
||||||
|
assert profile.profession == "研究员"
|
||||||
|
assert profile.interested_topics == ["AI", "政策", "社会"]
|
||||||
|
assert "None" not in json.dumps(profile.to_dict(), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalized_profile_serializes_to_twitter_and_reddit(tmp_path):
|
||||||
|
profile = OasisAgentProfile(
|
||||||
|
user_id=1,
|
||||||
|
user_name="agent",
|
||||||
|
name="Agent Name",
|
||||||
|
bio={"summary": "公开简介"},
|
||||||
|
persona=["详细", "人设"],
|
||||||
|
mbti={"text": "ENFP"},
|
||||||
|
interested_topics=["AI", ["政策"]],
|
||||||
|
)
|
||||||
|
generator = object.__new__(OasisProfileGenerator)
|
||||||
|
twitter_path = tmp_path / "twitter_profiles.csv"
|
||||||
|
reddit_path = tmp_path / "reddit_profiles.json"
|
||||||
|
|
||||||
|
generator._save_twitter_csv([profile], str(twitter_path))
|
||||||
|
generator._save_reddit_json([profile], str(reddit_path))
|
||||||
|
|
||||||
|
with twitter_path.open(encoding="utf-8") as handle:
|
||||||
|
twitter = next(csv.DictReader(handle))
|
||||||
|
reddit = json.loads(reddit_path.read_text(encoding="utf-8"))[0]
|
||||||
|
|
||||||
|
assert twitter["description"] == "公开简介"
|
||||||
|
assert twitter["user_char"] == "公开简介 详细, 人设"
|
||||||
|
assert reddit["bio"] == "公开简介"
|
||||||
|
assert reddit["persona"] == "详细, 人设"
|
||||||
|
assert reddit["mbti"] == "ENFP"
|
||||||
|
assert reddit["interested_topics"] == ["AI", "政策"]
|
||||||
Loading…
Reference in New Issue