添加全能创作功能
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"""全能创作 · @引用:实体检索 与 Ref 解析(契约 §1/§3)。
|
||||
|
||||
两件事:
|
||||
1. `search_mentions()` —— 输入框打 @ 时的检索,返回 [Ref] 给前端渲染菜单。
|
||||
2. `resolve_refs()` —— 把消息里的 [Ref] 变成模型真正吃得下的两样东西:
|
||||
**事实文本**(卖点/规格,进提示词)+ **参考图**(进 content_items,锁脸/锁商品/锁场景)。
|
||||
|
||||
铁律:消息里存的是结构化 Ref(type + id),**不是** "@净颜精华" 这串字。
|
||||
后端必须拿 id 回表取事实与图,靠字符串匹配迟早对不上。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from apps.assets.models import Asset, Model
|
||||
from apps.products.models import Product
|
||||
|
||||
from .services import _asset_preview_url, _product_cover_url
|
||||
|
||||
# Ref.type → 前端菜单里的分组名(设计稿 .omni-mention-group 的 small 文案)
|
||||
TYPE_LABELS = {
|
||||
"product": "商品库",
|
||||
"model": "模特库",
|
||||
"character": "角色",
|
||||
"scene": "场景库",
|
||||
"asset": "资产库",
|
||||
}
|
||||
VALID_TYPES = tuple(TYPE_LABELS)
|
||||
DEFAULT_TYPES = VALID_TYPES
|
||||
|
||||
# 参考图顺序固定:角色 → 场景 → 商品。这个顺序是出片模型 @图N 的语义依据,别改。
|
||||
_REF_ORDER = {"model": 0, "character": 0, "scene": 1, "product": 2, "asset": 3}
|
||||
# 火山单次出片最多 9 张图;留足余量,超出的靠优先级截断而不是报错
|
||||
MAX_REFERENCE_IMAGES = 6
|
||||
|
||||
# 「资产库」是兜底分组,不重复列已经有专属分组的资产 ——
|
||||
# 否则同一张定妆照会在「角色」和「资产库」各出现一次,菜单里看着像两个素材。
|
||||
ASSET_EXCLUDED_CATEGORIES = (
|
||||
Asset.Category.PERSON, # → character
|
||||
Asset.Category.SCENE, # → scene
|
||||
Asset.Category.MODEL_PORTRAIT, # → model
|
||||
Asset.Category.TRI_VIEW, # → model
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRefs:
|
||||
"""resolve_refs 的产物。facts 进提示词,references 进 content_items。"""
|
||||
|
||||
facts: list[str] = field(default_factory=list)
|
||||
references: list[dict] = field(default_factory=list)
|
||||
missing: list[dict] = field(default_factory=list) # 删掉/不属于本团队的引用,要在对话里告诉用户
|
||||
|
||||
@property
|
||||
def facts_text(self) -> str:
|
||||
return "\n\n".join(self.facts)
|
||||
|
||||
|
||||
def _ref(type_: str, obj_id, name: str, cover: str = "") -> dict:
|
||||
return {"type": type_, "id": str(obj_id), "name": name, "cover": cover}
|
||||
|
||||
|
||||
def _search_products(team, q: str, limit: int) -> list[dict]:
|
||||
queryset = Product.objects.filter(team=team, purged_at__isnull=True)
|
||||
if q:
|
||||
queryset = queryset.filter(title__icontains=q)
|
||||
out = []
|
||||
for product in queryset.order_by("-created_at")[:limit]:
|
||||
out.append(_ref("product", product.id, product.title, _product_cover_url(product)))
|
||||
return out
|
||||
|
||||
|
||||
def _search_models(team, q: str, limit: int) -> list[dict]:
|
||||
queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
|
||||
if q:
|
||||
queryset = queryset.filter(name__icontains=q)
|
||||
out = []
|
||||
for model in queryset.select_related("portrait_asset")[:limit]:
|
||||
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
|
||||
return out
|
||||
|
||||
|
||||
def _search_assets(team, q: str, limit: int, categories: tuple[str, ...], type_: str) -> list[dict]:
|
||||
queryset = Asset.objects.filter(
|
||||
team=team,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
category__in=categories,
|
||||
)
|
||||
if type_ == "asset":
|
||||
# 「资产库」只列用户真正加进库的图,不然工作台的每张试验图都会冒出来
|
||||
queryset = queryset.filter(in_library=True)
|
||||
if q:
|
||||
queryset = queryset.filter(name__icontains=q)
|
||||
out = []
|
||||
for asset in queryset.order_by("-created_at")[:limit]:
|
||||
out.append(_ref(type_, asset.id, asset.name, _asset_preview_url(asset)))
|
||||
return out
|
||||
|
||||
|
||||
def search_mentions(team, q: str = "", types: list[str] | None = None, limit: int = 8) -> list[dict]:
|
||||
"""@ 检索。types 不传则全类型各取 limit 条,按 商品 → 模特 → 角色 → 场景 → 资产 排。"""
|
||||
wanted = [t for t in (types or DEFAULT_TYPES) if t in TYPE_LABELS]
|
||||
q = (q or "").strip()
|
||||
results: list[dict] = []
|
||||
for type_ in wanted:
|
||||
if type_ == "product":
|
||||
results.extend(_search_products(team, q, limit))
|
||||
elif type_ == "model":
|
||||
results.extend(_search_models(team, q, limit))
|
||||
elif type_ == "character":
|
||||
results.extend(_search_assets(team, q, limit, (Asset.Category.PERSON,), "character"))
|
||||
elif type_ == "scene":
|
||||
results.extend(_search_assets(team, q, limit, (Asset.Category.SCENE,), "scene"))
|
||||
elif type_ == "asset":
|
||||
categories = tuple(
|
||||
c for c in Asset.Category.values if c not in ASSET_EXCLUDED_CATEGORIES
|
||||
)
|
||||
results.extend(_search_assets(team, q, limit, categories, "asset"))
|
||||
return results
|
||||
|
||||
|
||||
_KEY_TO_TYPES = {
|
||||
"product": ["product"],
|
||||
"sku": ["product"],
|
||||
"goods": ["product"],
|
||||
"item": ["product"],
|
||||
"model": ["model"],
|
||||
"character": ["character"],
|
||||
"person": ["character"],
|
||||
"scene": ["scene"],
|
||||
"asset": ["asset"],
|
||||
}
|
||||
|
||||
|
||||
def infer_field_types(field: dict) -> list[str]:
|
||||
"""追问卡字段 → 该去哪类库里解析用户的选择。"""
|
||||
typed = [t for t in (field.get("asset_types") or []) if t in TYPE_LABELS]
|
||||
if typed:
|
||||
return typed
|
||||
key = str(field.get("key") or "").strip().lower()
|
||||
if key in _KEY_TO_TYPES:
|
||||
return _KEY_TO_TYPES[key]
|
||||
label = str(field.get("label") or "")
|
||||
if "商品" in label:
|
||||
return ["product"]
|
||||
if "模特" in label:
|
||||
return ["model"]
|
||||
if "角色" in label or "人物" in label:
|
||||
return ["character"]
|
||||
if "场景" in label:
|
||||
return ["scene"]
|
||||
return list(DEFAULT_TYPES)
|
||||
|
||||
|
||||
def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | None:
|
||||
"""把追问卡里的选项值(实体 id 或精确名字)还原成 Ref。对不上就返回 None,绝不瞎配。"""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
wanted = [t for t in (types or DEFAULT_TYPES) if t in TYPE_LABELS] or list(DEFAULT_TYPES)
|
||||
uid = None
|
||||
try:
|
||||
uid = str(uuid.UUID(raw))
|
||||
except ValueError:
|
||||
uid = None
|
||||
if uid:
|
||||
if "product" in wanted:
|
||||
product = Product.objects.filter(team=team, id=uid, purged_at__isnull=True).first()
|
||||
if product:
|
||||
return _ref("product", product.id, product.title, _product_cover_url(product))
|
||||
if "model" in wanted:
|
||||
model = (
|
||||
Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("portrait_asset")
|
||||
.first()
|
||||
)
|
||||
if model:
|
||||
return _ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset))
|
||||
if any(t in wanted for t in ("character", "scene", "asset")):
|
||||
asset = Asset.objects.filter(
|
||||
team=team, id=uid, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if asset is not None:
|
||||
if asset.category == Asset.Category.PERSON:
|
||||
type_ = "character"
|
||||
elif asset.category == Asset.Category.SCENE:
|
||||
type_ = "scene"
|
||||
else:
|
||||
type_ = "asset"
|
||||
if type_ in wanted:
|
||||
return _ref(type_, asset.id, asset.name, _asset_preview_url(asset))
|
||||
if types:
|
||||
return lookup_mention(team, raw, None)
|
||||
return None
|
||||
hits = search_mentions(team, q=raw, types=wanted, limit=8)
|
||||
for hit in hits:
|
||||
if (hit.get("name") or "") == raw:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def refs_from_elicit_answers(team, fields, answers: dict) -> list[dict]:
|
||||
"""用户在追问卡里点选的商品/角色等 → 可 pin 的 Ref。
|
||||
模型常用 type=single + 选项 value=商品id/名字,前端只回 answers 不回 refs,
|
||||
不在这里补上的话出片参考图里就没有这件商品。"""
|
||||
refs: list[dict] = []
|
||||
seen: set[tuple] = set()
|
||||
for field in fields or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
key = str(field.get("key") or "")
|
||||
if key in {"duration", "ratio", "resolution", "video_model", "count"}:
|
||||
continue
|
||||
raw = (answers or {}).get(field.get("key"))
|
||||
if raw is None:
|
||||
continue
|
||||
values = raw if isinstance(raw, list) else [raw]
|
||||
inferred = infer_field_types(field)
|
||||
for value in values:
|
||||
ref = lookup_mention(team, str(value or ""), inferred)
|
||||
if ref is None:
|
||||
continue
|
||||
mark = (ref.get("type"), str(ref.get("id")))
|
||||
if mark in seen:
|
||||
continue
|
||||
seen.add(mark)
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def product_facts_text(product) -> str:
|
||||
"""商品事实块。全能创作没有 project,所以不能复用 script_agent._product_context()。
|
||||
这里只给**客观事实**(标题/品牌/品类/规格/卖点),不带人设和口吻 —— 那些由策略卡决定。"""
|
||||
lines = [f"商品:{product.title}"]
|
||||
if product.brand:
|
||||
lines.append(f"品牌:{product.brand}")
|
||||
if product.category:
|
||||
lines.append(f"品类:{product.category}")
|
||||
if product.target_audience:
|
||||
lines.append(f"目标人群:{product.target_audience}")
|
||||
description = (product.description or "").strip()
|
||||
if description:
|
||||
lines.append(f"商品描述:{description}")
|
||||
specs = product.specs if isinstance(product.specs, dict) else {}
|
||||
spec_text = "、".join(f"{k}:{v}" for k, v in specs.items() if v)
|
||||
if spec_text:
|
||||
lines.append(f"规格:{spec_text}")
|
||||
points = list(product.selling_points.order_by("sort_order", "created_at"))
|
||||
if points:
|
||||
joined = "\n".join(f"- {p.title}:{p.detail or p.title}" for p in points)
|
||||
lines.append(f"卖点:\n{joined}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _asset_reference(asset, type_: str, label: str) -> dict | None:
|
||||
"""Asset → 参考图条目。带上审核态,视频路据此换成火山 asset:// 引用(否则真人图会被判「疑似真人」拒)。"""
|
||||
url = _asset_preview_url(asset)
|
||||
if not url:
|
||||
return None
|
||||
return {
|
||||
"url": url,
|
||||
"type": type_,
|
||||
"label": label,
|
||||
"asset_id": str(asset.id),
|
||||
"review_status": asset.review_status,
|
||||
"review_remote_id": asset.review_remote_id,
|
||||
}
|
||||
|
||||
|
||||
def _product_reference(product) -> dict | None:
|
||||
"""商品参考图:**真实上传图优先,排除 AI 生成图** —— 拿生成图当真相再喂回模型会误差累积。
|
||||
一张真实图都没有才回落封面(可能是 AI 图,但好过纯文生图)。
|
||||
|
||||
这里不复用 services._product_reference_urls():那个只返回 url,而视频路还需要
|
||||
asset_id 和审核态才能把图换成火山 asset:// 引用(商品图也可能出现真人上身)。
|
||||
"""
|
||||
rels = sorted(product.images.select_related("asset").all(), key=lambda im: (not im.is_primary, im.sort_order))
|
||||
for rel in rels:
|
||||
asset = rel.asset
|
||||
if asset is None or asset.source == Asset.Source.AI_GENERATED:
|
||||
continue
|
||||
entry = _asset_reference(asset, "product", product.title)
|
||||
if entry:
|
||||
return entry
|
||||
if product.cover_asset_id:
|
||||
entry = _asset_reference(product.cover_asset, "product", product.title)
|
||||
if entry:
|
||||
return entry
|
||||
cover = _product_cover_url(product)
|
||||
return (
|
||||
{"url": cover, "type": "product", "label": product.title,
|
||||
"asset_id": "", "review_status": "", "review_remote_id": ""}
|
||||
if cover else None
|
||||
)
|
||||
|
||||
|
||||
def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
||||
"""[Ref] → 事实文本 + 参考图。查不到的进 missing,**不抛异常** ——
|
||||
素材被别人删掉不该让整条对话崩掉,该由 agent 在对话里说明。"""
|
||||
resolved = ResolvedRefs()
|
||||
for ref in refs or []:
|
||||
type_, ref_id = ref.get("type"), ref.get("id")
|
||||
if type_ not in TYPE_LABELS or not ref_id:
|
||||
continue
|
||||
if type_ == "product":
|
||||
product = Product.objects.filter(team=team, id=ref_id, purged_at__isnull=True).first()
|
||||
if product is None:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
resolved.facts.append(product_facts_text(product))
|
||||
entry = _product_reference(product)
|
||||
if entry:
|
||||
resolved.references.append(entry)
|
||||
continue
|
||||
if type_ == "model":
|
||||
model = Model.objects.filter(
|
||||
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
|
||||
).select_related("triview_asset", "portrait_asset").first()
|
||||
if model is None:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
if (model.description or "").strip():
|
||||
resolved.facts.append(f"模特「{model.name}」:{model.description.strip()}")
|
||||
# 锁脸优先用三视图(正/侧/背都在一张 16:9 里,信息量最大),没有才回落形象图
|
||||
entry = _asset_reference(model.triview_asset, "model", model.name) or _asset_reference(
|
||||
model.portrait_asset, "model", model.name
|
||||
)
|
||||
if entry:
|
||||
resolved.references.append(entry)
|
||||
else:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
asset = Asset.objects.filter(
|
||||
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if asset is None:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
if (asset.description or "").strip():
|
||||
resolved.facts.append(f"{TYPE_LABELS[type_]}「{asset.name}」:{asset.description.strip()}")
|
||||
entry = _asset_reference(asset, type_, asset.name)
|
||||
if entry:
|
||||
resolved.references.append(entry)
|
||||
else:
|
||||
resolved.missing.append(ref)
|
||||
|
||||
# 角色 → 场景 → 商品。同优先级内保持用户 @ 的先后。
|
||||
resolved.references.sort(key=lambda item: _REF_ORDER.get(item["type"], 9))
|
||||
resolved.references = _dedupe_references(resolved.references)[:MAX_REFERENCE_IMAGES]
|
||||
return resolved
|
||||
|
||||
|
||||
def _dedupe_references(references: list[dict]) -> list[dict]:
|
||||
"""同一张图被 @ 两次(比如商品图同时是资产库图)只留一条,否则 @图N 编号会错位。"""
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in references:
|
||||
key = item.get("url") or ""
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
return out
|
||||
Reference in New Issue
Block a user