Skip to content

Commit 1679729

Browse files
authored
feat: per-request prompt_traits injection layer (#17)
* feat(core): add PromptTrait + extend Event::UserMessage with prompt_traits * feat(prompt): render 【附加指引】 section from prompt_traits * refactor(handlers): import PromptTrait + comment GiftHandler &[] rationale * feat(routes): add PromptTraitDto + validate_prompt_traits * feat(routes): thread prompt_traits through /message + /message_async with tracing * test(routes): integration tests for prompt_traits validation * chore(openapi): regenerate snapshot for prompt_traits * docs(api): document prompt_traits on /message + /message_async * docs: add prompt-traits reference (EN + ZH) * docs(readme): mention prompt_traits on message routes * chore: cargo fmt * fix(validate): reject U+2028/2029 + trim text before length check
1 parent a22d78e commit 1679729

13 files changed

Lines changed: 798 additions & 7 deletions

File tree

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ Highlights:
194194
- `GET /comp/user/{user_id}/profile` — current `companion_insights` and `training_level`.
195195
- `POST /comp/chat/{session_id}/event/gift` — apply an out-of-band gift event and affinity delta.
196196
- `GET /comp/chat/{session_id}/gifts` — list gift events for a session.
197+
- `POST /comp/chat/{session_id}/message` and `/message_async` accept an optional
198+
`prompt_traits` field for per-request system-prompt injection — see
199+
[docs/prompt-traits.md](docs/prompt-traits.md).
197200
- `GET /comp/affinity/{session_id}` — debug-only live affinity vector, enabled by `EXPOSE_AFFINITY_DEBUG=true`.
198201

199202
The `AuthValidator` trait is pluggable if you use a different identity provider.
@@ -228,7 +231,16 @@ If you are building a different product, the reusable part is the affinity + mem
228231

229232
## Content note
230233

231-
The example personas under `examples/personas/` are written as adult character-chat examples. They can flirt and express desire when the relationship state reaches that point, while still refusing disrespectful or boundary-crossing behavior. If your product needs a SFW default, replace those persona files before deploying.
234+
The example personas under `examples/personas/` are written as adult
235+
character-chat examples. They can flirt and express desire when the
236+
relationship state reaches that point, while still refusing disrespectful or
237+
boundary-crossing behavior. If your product needs a SFW default, replace
238+
those persona files before deploying.
239+
240+
Per-request behaviour can be further modulated via the
241+
[`prompt_traits`](docs/prompt-traits.md) field on the message routes —
242+
the engine treats the supplied text as opaque, so the policy of what
243+
those traits encode lives entirely in your frontend / middleware.
232244

233245
## Contributing
234246

README.zh.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ Server 默認監聽 `0.0.0.0:8080`。Scalar API docs 在 `/docs`,OpenAPI JSON
154154
- `GET /comp/user/{user_id}/profile`——讀取目前的 `companion_insights``training_level`
155155
- `POST /comp/chat/{session_id}/event/gift`——套用外部 gift event 與 affinity delta。
156156
- `GET /comp/chat/{session_id}/gifts`——列出某個 session 的 gift events。
157+
- `POST /comp/chat/{session_id}/message``/message_async` 接受可選的
158+
`prompt_traits` 欄位,用於 per-request system-prompt 注入——詳見
159+
[docs/prompt-traits.md](docs/prompt-traits.md)
157160
- `GET /comp/affinity/{session_id}`——debug-only 即時 affinity vector,由 `EXPOSE_AFFINITY_DEBUG=true` 開啟。
158161

159162
如果你不用 Supabase,可以實現 `AuthValidator` trait 接自己的 identity provider。
@@ -187,6 +190,11 @@ Server 默認監聽 `0.0.0.0:8080`。Scalar API docs 在 `/docs`,OpenAPI JSON
187190

188191
`examples/personas/` 裡的人格是成人 character-chat 示例。當 relationship state 走到相應位置,它們可以調情、表達慾望;同時仍會拒絕不尊重或越界的要求。如果你的產品需要 SFW default,部署前請替換這些 persona files。
189192

193+
每一輪的行為還可以透過 message routes 上的
194+
[`prompt_traits`](docs/prompt-traits.md) 欄位再調整——engine 把傳入的文字
195+
當成 opaque string 處理,這些 traits 實際代表什麼策略,完全交給你的
196+
frontend / middleware 決定。
197+
190198
## 貢獻
191199

192200
請先閱讀 [`CONTRIBUTING.md`](CONTRIBUTING.md)。所有貢獻者首次提 PR 時都需要透過 cla-assistant.io 接受 [`CLA`](CLA.md)

crates/eros-engine-core/src/pde.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ mod tests {
206206
Event::UserMessage {
207207
content: content.into(),
208208
message_id: Uuid::new_v4(),
209+
prompt_traits: Vec::new(),
209210
}
210211
}
211212

crates/eros-engine-core/src/types.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,32 @@ use uuid::Uuid;
77
use crate::affinity::{Affinity, AffinityDeltas};
88
use crate::persona::CompanionPersona;
99

10+
/// A caller-supplied system-prompt fragment. The engine treats `text` as
11+
/// opaque — it is inserted verbatim under the `【附加指引】` section of
12+
/// the persona system prompt. `tag` is for logging/observability only and
13+
/// is constrained to `[a-z0-9_]{1,32}` by the HTTP layer.
14+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15+
pub struct PromptTrait {
16+
pub tag: String,
17+
pub text: String,
18+
}
19+
1020
/// Events that drive the engine pipeline.
1121
#[derive(Debug, Clone, Serialize, Deserialize)]
1222
pub enum Event {
13-
UserMessage { content: String, message_id: Uuid },
14-
Gift { gift_id: Uuid, amount: i64 },
23+
UserMessage {
24+
content: String,
25+
message_id: Uuid,
26+
/// Optional caller-supplied prompt traits. Empty for clients that
27+
/// don't send the field — preserves the legacy system-prompt output
28+
/// byte-for-byte.
29+
#[serde(default)]
30+
prompt_traits: Vec<PromptTrait>,
31+
},
32+
Gift {
33+
gift_id: Uuid,
34+
amount: i64,
35+
},
1536
ProactiveTrigger,
1637
AppOpen,
1738
}
@@ -68,3 +89,31 @@ pub struct DecisionInput {
6889
pub persona: CompanionPersona,
6990
pub signals: ConversationSignals,
7091
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
97+
#[test]
98+
fn event_user_message_defaults_prompt_traits_to_empty_vec() {
99+
let raw = r#"{"UserMessage":{"content":"hi","message_id":"00000000-0000-0000-0000-000000000001"}}"#;
100+
let ev: Event = serde_json::from_str(raw).expect("legacy body deserialises");
101+
match ev {
102+
Event::UserMessage { prompt_traits, .. } => {
103+
assert!(prompt_traits.is_empty(), "missing field must default to []");
104+
}
105+
_ => panic!("expected UserMessage"),
106+
}
107+
}
108+
109+
#[test]
110+
fn prompt_trait_round_trips_through_serde() {
111+
let t = PromptTrait {
112+
tag: "nsfw_boost".into(),
113+
text: "be more daring".into(),
114+
};
115+
let json = serde_json::to_string(&t).unwrap();
116+
let back: PromptTrait = serde_json::from_str(&json).unwrap();
117+
assert_eq!(back, t);
118+
}
119+
}

crates/eros-engine-server/openapi.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,24 @@
11931193
}
11941194
}
11951195
},
1196+
"PromptTraitDto": {
1197+
"type": "object",
1198+
"description": "Caller-supplied prompt-injection fragment. See `docs/prompt-traits.md`.",
1199+
"required": [
1200+
"tag",
1201+
"text"
1202+
],
1203+
"properties": {
1204+
"tag": {
1205+
"type": "string",
1206+
"description": "ASCII identifier, regex `^[a-z0-9_]{1,32}$`. Used for logging."
1207+
},
1208+
"text": {
1209+
"type": "string",
1210+
"description": "Verbatim text inserted under `【附加指引】` in the system prompt.\n1 ≤ chars ≤ 2000 after trim."
1211+
}
1212+
}
1213+
},
11961214
"SendMessageRequest": {
11971215
"type": "object",
11981216
"required": [
@@ -1201,6 +1219,16 @@
12011219
"properties": {
12021220
"message": {
12031221
"type": "string"
1222+
},
1223+
"prompt_traits": {
1224+
"type": [
1225+
"array",
1226+
"null"
1227+
],
1228+
"items": {
1229+
"$ref": "#/components/schemas/PromptTraitDto"
1230+
},
1231+
"description": "Optional caller-supplied prompt-injection fragments. See\n`docs/prompt-traits.md`. Missing field → empty list."
12041232
}
12051233
}
12061234
},

crates/eros-engine-server/src/pipeline/handlers.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use sqlx::PgPool;
2222
use uuid::Uuid;
2323

2424
use eros_engine_core::affinity::AffinityDeltas;
25-
use eros_engine_core::types::{ActionPlan, DecisionInput, Event};
25+
use eros_engine_core::types::{ActionPlan, DecisionInput, Event, PromptTrait};
2626
use eros_engine_llm::openrouter::{ChatMessage, ChatRequest};
2727
use eros_engine_store::chat::ChatRepo;
2828
use eros_engine_store::insight::InsightRepo;
@@ -355,6 +355,11 @@ impl<'a> ActionHandler for ReplyHandler<'a> {
355355
// Reply path never has pending gifts — those flow through GiftHandler.
356356
let pending_gifts: Vec<PendingGift> = vec![];
357357

358+
let prompt_traits: &[PromptTrait] = match &input.event {
359+
Event::UserMessage { prompt_traits, .. } => prompt_traits.as_slice(),
360+
_ => &[],
361+
};
362+
358363
let system_prompt = build_prompt(
359364
&input.persona,
360365
&profile_groups,
@@ -364,6 +369,7 @@ impl<'a> ActionHandler for ReplyHandler<'a> {
364369
tip_personality,
365370
plan.reply_style,
366371
&plan.context_hints,
372+
prompt_traits,
367373
);
368374

369375
Ok(Some(assemble_chat_request(
@@ -461,6 +467,7 @@ impl<'a> ActionHandler for GiftHandler<'a> {
461467
.as_deref()
462468
.unwrap_or("normal");
463469

470+
// Gift path: prompt_traits flow only from UserMessage; ignore for gift reactions.
464471
let system_prompt = build_prompt(
465472
&input.persona,
466473
&profile_groups,
@@ -470,6 +477,7 @@ impl<'a> ActionHandler for GiftHandler<'a> {
470477
tip_personality,
471478
plan.reply_style,
472479
&plan.context_hints,
480+
&[],
473481
);
474482

475483
Ok(Some(assemble_chat_request(

crates/eros-engine-server/src/pipeline/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ pub async fn run(
7373
plan.reply_style,
7474
);
7575

76+
if let Event::UserMessage { prompt_traits, .. } = &event {
77+
if !prompt_traits.is_empty() {
78+
let tags: Vec<&str> = prompt_traits.iter().map(|t| t.tag.as_str()).collect();
79+
tracing::info!(
80+
session = %session_id,
81+
traits_count = prompt_traits.len(),
82+
trait_tags = ?tags,
83+
"engine: prompt_traits applied"
84+
);
85+
}
86+
}
87+
7688
// 7. Dispatch to handler. The Gift branch passes `plan.affinity_deltas`
7789
// through; T11 will replace this with deltas supplied directly by the
7890
// `/comp/chat/{id}/event/gift` route's request body, since the OSS

crates/eros-engine-server/src/prompt.rs

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use chrono::{Timelike, Utc};
2727

2828
use eros_engine_core::affinity::Affinity;
2929
use eros_engine_core::persona::CompanionPersona;
30+
use eros_engine_core::types::PromptTrait;
3031
use eros_engine_core::types::ReplyStyle;
3132

3233
/// A pending gift/tip that the prompt builder must surface to the LLM.
@@ -216,6 +217,7 @@ pub fn build_prompt(
216217
tip_personality: &str,
217218
style: ReplyStyle,
218219
hints: &[String],
220+
prompt_traits: &[PromptTrait],
219221
) -> String {
220222
let name = persona.genome.name.as_str();
221223
let age = meta_i32(persona, "age")
@@ -229,6 +231,17 @@ pub fn build_prompt(
229231
let topics_str =
230232
meta_string_array_joined(persona, "topics").unwrap_or_else(|| "日常生活、感情观".into());
231233

234+
let traits_section = if prompt_traits.is_empty() {
235+
String::new()
236+
} else {
237+
let bullets = prompt_traits
238+
.iter()
239+
.map(|t| format!("- {}", t.text))
240+
.collect::<Vec<_>>()
241+
.join("\n");
242+
format!("\n\n【附加指引】\n{bullets}")
243+
};
244+
232245
let non_empty_groups: Vec<&(String, Vec<String>)> = profile_groups
233246
.iter()
234247
.filter(|(_, items)| !items.is_empty())
@@ -294,7 +307,7 @@ pub fn build_prompt(
294307
\n\
295308
【说话风格】{speech_style}\n\
296309
【口癖/习惯】{quirks_str}\n\
297-
【擅长话题】{topics_str}\n\
310+
【擅长话题】{topics_str}{traits_section}\n\
298311
\n\
299312
【今日情境】\n{tc}\n\
300313
\n\
@@ -436,6 +449,122 @@ pub fn extract_structured_insights_prompt(
436449
#[cfg(test)]
437450
mod tests {
438451
use super::*;
452+
use uuid::Uuid;
453+
454+
fn fixture_persona() -> CompanionPersona {
455+
use eros_engine_core::persona::{PersonaGenome, PersonaInstance};
456+
let uid = Uuid::nil();
457+
CompanionPersona {
458+
instance_id: uid,
459+
genome: PersonaGenome {
460+
id: uid,
461+
name: "Aria".into(),
462+
system_prompt: "p".into(),
463+
tip_personality: Some("normal".into()),
464+
avatar_url: None,
465+
art_metadata: serde_json::json!({
466+
"age": 24,
467+
"mbti": "INFP",
468+
"backstory": "back",
469+
"speech_style": "soft",
470+
"quirks": ["q1"],
471+
"topics": ["t1"]
472+
}),
473+
is_active: true,
474+
},
475+
instance: PersonaInstance {
476+
id: uid,
477+
genome_id: uid,
478+
owner_uid: uid,
479+
status: "active".into(),
480+
},
481+
}
482+
}
483+
484+
#[test]
485+
fn build_prompt_with_empty_traits_omits_section_and_preserves_layout() {
486+
let p = build_prompt(
487+
&fixture_persona(),
488+
&[],
489+
&[],
490+
None,
491+
&[],
492+
"normal",
493+
ReplyStyle::Neutral,
494+
&[],
495+
&[],
496+
);
497+
assert!(
498+
!p.contains("【附加指引】"),
499+
"empty traits must not render section"
500+
);
501+
// Byte-level invariant proving "byte-for-byte identical to legacy"
502+
// for the empty-traits case: topics → time-context joins with the
503+
// pre-existing `\n\n` separator, no leftover whitespace from the
504+
// new `{traits_section}` placeholder.
505+
assert!(
506+
p.contains("【擅长话题】t1\n\n【今日情境】"),
507+
"topics → time-context separator must be exactly '\\n\\n'"
508+
);
509+
}
510+
511+
#[test]
512+
fn build_prompt_renders_traits_as_bullets_under_label() {
513+
let traits = vec![
514+
PromptTrait {
515+
tag: "nsfw_boost".into(),
516+
text: "be more daring".into(),
517+
},
518+
PromptTrait {
519+
tag: "politics_open".into(),
520+
text: "discuss politics openly".into(),
521+
},
522+
];
523+
let p = build_prompt(
524+
&fixture_persona(),
525+
&[],
526+
&[],
527+
None,
528+
&[],
529+
"normal",
530+
ReplyStyle::Neutral,
531+
&[],
532+
&traits,
533+
);
534+
assert!(p.contains("【附加指引】"), "section header present");
535+
assert!(p.contains("- be more daring"));
536+
assert!(p.contains("- discuss politics openly"));
537+
// Ordering preserved.
538+
let i1 = p.find("be more daring").unwrap();
539+
let i2 = p.find("discuss politics openly").unwrap();
540+
assert!(i1 < i2, "traits render in input order");
541+
}
542+
543+
#[test]
544+
fn build_prompt_section_sits_between_topics_and_time_context() {
545+
let traits = vec![PromptTrait {
546+
tag: "x".into(),
547+
text: "trait body".into(),
548+
}];
549+
let p = build_prompt(
550+
&fixture_persona(),
551+
&[],
552+
&[],
553+
None,
554+
&[],
555+
"normal",
556+
ReplyStyle::Neutral,
557+
&[],
558+
&traits,
559+
);
560+
let topics_idx = p.find("【擅长话题】").expect("topics header");
561+
let traits_idx = p.find("【附加指引】").expect("traits header");
562+
let time_idx = p.find("【今日情境】").expect("time-context header");
563+
assert!(
564+
topics_idx < traits_idx && traits_idx < time_idx,
565+
"order: 擅长话题 → 附加指引 → 今日情境"
566+
);
567+
}
439568

440569
#[test]
441570
fn test_style_directive_for_all_styles() {

0 commit comments

Comments
 (0)