Skip to content

Commit 73e7a3d

Browse files
committed
new apporach
1 parent a185abb commit 73e7a3d

3 files changed

Lines changed: 126 additions & 187 deletions

File tree

src/server/core/acontext_core/service/controller/message.py

Lines changed: 0 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -6,46 +6,11 @@
66
from ...schema.utils import asUUID
77
from ...schema.result import Result
88
from ...llm.agent import task as AT
9-
from ...llm.complete import llm_complete
109
from ...env import LOG
1110
from ...schema.config import ProjectConfig
1211
from ...telemetry.log import get_wide_event
1312
from ...telemetry.get_metrics import get_metrics
1413
from ...constants import ExcessMetricTags
15-
from ..data import session as SD
16-
17-
TITLE_INPUT_MAX_CHARS = 512
18-
TITLE_INPUT_MIN_CHARS = 12
19-
TITLE_GENERATION_MAX_TOKENS = 24
20-
TITLE_OUTPUT_MAX_CHARS = 80
21-
NON_INFORMATIVE_TITLE_INPUTS = {
22-
"hi",
23-
"hello",
24-
"hey",
25-
"ok",
26-
"okay",
27-
"thanks",
28-
"thank you",
29-
"test",
30-
"testing",
31-
}
32-
TITLE_GENERATION_SYSTEM_PROMPT = """You generate concise session titles.
33-
Given a user's first message, return one short, informative title.
34-
Rules:
35-
- 3 to 8 words.
36-
- Use plain text only.
37-
- Do not use quotes.
38-
- Do not include punctuation at the end.
39-
"""
40-
41-
42-
def normalize_title_input_text(text: str, max_chars: int = TITLE_INPUT_MAX_CHARS) -> str | None:
43-
normalized = " ".join(text.strip().split())
44-
if normalized == "":
45-
return None
46-
if len(normalized) > max_chars:
47-
normalized = normalized[:max_chars].rstrip()
48-
return normalized
4914

5015

5116
async def _try_rollback_to_failed(pending_message_ids: list) -> None:
@@ -59,84 +24,6 @@ async def _try_rollback_to_failed(pending_message_ids: list) -> None:
5924
"session.pending_message_rollback_failed",
6025
pending_message_ids=[str(mid) for mid in pending_message_ids],
6126
)
62-
def check_title_input_quality(text: str | None) -> tuple[bool, str]:
63-
if text is None:
64-
return False, "empty"
65-
normalized = normalize_title_input_text(text)
66-
if normalized is None:
67-
return False, "empty"
68-
if len(normalized) < TITLE_INPUT_MIN_CHARS:
69-
return False, "too_short"
70-
if normalized.lower() in NON_INFORMATIVE_TITLE_INPUTS:
71-
return False, "non_informative"
72-
return True, "ok"
73-
74-
75-
def extract_first_user_message_text(messages: list[MessageBlob]) -> str | None:
76-
for message in messages:
77-
if message.role != "user":
78-
continue
79-
text_parts = [
80-
part.text.strip()
81-
for part in message.parts
82-
if part.type == "text"
83-
and isinstance(part.text, str)
84-
and part.text.strip() != ""
85-
]
86-
if text_parts:
87-
return normalize_title_input_text("\n".join(text_parts))
88-
return None
89-
90-
91-
def sanitize_generated_title(
92-
title_candidate: str | None,
93-
fallback_text: str | None,
94-
max_chars: int = TITLE_OUTPUT_MAX_CHARS,
95-
) -> str | None:
96-
def _clean(text: str | None) -> str | None:
97-
if text is None:
98-
return None
99-
cleaned = " ".join(text.replace("\n", " ").replace("\r", " ").split())
100-
cleaned = cleaned.strip("`'\"“”‘’ ").strip()
101-
if cleaned == "":
102-
return None
103-
if len(cleaned) > max_chars:
104-
cleaned = cleaned[:max_chars].rstrip()
105-
cleaned = cleaned.strip("`'\"“”‘’ ").strip()
106-
if cleaned == "":
107-
return None
108-
if not any(ch.isalnum() for ch in cleaned):
109-
return None
110-
return cleaned
111-
112-
cleaned_title = _clean(title_candidate)
113-
if cleaned_title is not None:
114-
return cleaned_title
115-
116-
cleaned_fallback = _clean(fallback_text)
117-
if cleaned_fallback is None:
118-
return None
119-
return " ".join(cleaned_fallback.split()[:8])
120-
121-
122-
async def generate_session_title_candidate(
123-
first_user_message_text: str,
124-
) -> Result[str | None]:
125-
r = await llm_complete(
126-
system_prompt=TITLE_GENERATION_SYSTEM_PROMPT,
127-
history_messages=[{"role": "user", "content": first_user_message_text}],
128-
max_tokens=TITLE_GENERATION_MAX_TOKENS,
129-
prompt_kwargs={"prompt_id": "session.display_title.first_user"},
130-
)
131-
llm_response, eil = r.unpack()
132-
if eil:
133-
return Result.reject(eil.errmsg)
134-
if llm_response.content is None:
135-
return Result.resolve(None)
136-
title_candidate = llm_response.content.strip()
137-
if title_candidate == "":
138-
return Result.resolve(None)
139-
return Result.resolve(title_candidate)
14027

14128

14229
async def process_session_pending_message(
@@ -197,79 +84,6 @@ async def process_session_pending_message(
19784
)
19885
for m in messages
19986
]
200-
first_user_message_text = None
201-
try:
202-
r = await SD.should_generate_session_display_title(
203-
db_session, session_id
204-
)
205-
should_generate_title, eil = r.unpack()
206-
if eil:
207-
raise ValueError(eil.errmsg)
208-
if not should_generate_title:
209-
LOG.debug(
210-
f"Session {session_id} already has display_title, "
211-
"skip title-input extraction"
212-
)
213-
else:
214-
first_user_message_text = extract_first_user_message_text(
215-
messages_data
216-
)
217-
is_quality_ok, quality_reason = check_title_input_quality(
218-
first_user_message_text
219-
)
220-
if not is_quality_ok:
221-
first_user_message_text = None
222-
LOG.debug(
223-
f"Skip title-input generation for session {session_id}: "
224-
f"{quality_reason}"
225-
)
226-
else:
227-
LOG.debug(
228-
f"Extracted first user text from pending session {session_id}, "
229-
f"length={len(first_user_message_text)}"
230-
)
231-
except Exception as title_gate_err:
232-
first_user_message_text = None
233-
LOG.warning(
234-
f"Skip title extraction for session {session_id}: {title_gate_err}"
235-
)
236-
237-
if first_user_message_text is not None:
238-
try:
239-
title_candidate = None
240-
title_result = await generate_session_title_candidate(
241-
first_user_message_text
242-
)
243-
title_candidate_raw, eil = title_result.unpack()
244-
if eil:
245-
raise ValueError(eil.errmsg)
246-
247-
title_candidate = sanitize_generated_title(
248-
title_candidate_raw, first_user_message_text
249-
)
250-
if title_candidate is None:
251-
LOG.debug(
252-
f"Title generation produced unusable content for session {session_id}"
253-
)
254-
else:
255-
LOG.debug(
256-
f"Generated session title candidate for session {session_id}: "
257-
f"{title_candidate[:80]}"
258-
)
259-
260-
if title_candidate is not None:
261-
async with DB_CLIENT.get_session_context() as db_session:
262-
r = await SD.update_session_display_title(
263-
db_session, session_id, title_candidate
264-
)
265-
_, eil = r.unpack()
266-
if eil:
267-
raise ValueError(eil.errmsg)
268-
LOG.debug(f"Persisted display_title for session {session_id}")
269-
except Exception as title_err:
270-
LOG.warning(
271-
f"Skip title generation/persist for session {session_id}: {title_err}"
272-
)
27387

27488
ls_session = None
27589
async with DB_CLIENT.get_session_context() as session:

src/server/core/acontext_core/service/data/task.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from sqlalchemy.orm import selectinload
44
from sqlalchemy.orm.attributes import flag_modified
55
from sqlalchemy.ext.asyncio import AsyncSession
6-
from ...env import LOG
76
from ...schema.orm import Task, Message
87
from ...schema.result import Result
98
from ...schema.utils import asUUID
109
from ...schema.session.task import TaskSchema
10+
from . import session as SD
1111

1212

1313
async def fetch_planning_task(
@@ -102,6 +102,14 @@ async def fetch_first_task_description(
102102
return Result.resolve(description or None)
103103

104104

105+
async def _sync_session_display_title(
106+
db_session: AsyncSession, session_id: asUUID
107+
) -> None:
108+
title, eil = (await fetch_first_task_description(db_session, session_id)).unpack()
109+
if eil is None and title:
110+
await SD.update_session_display_title_once(db_session, session_id, title)
111+
112+
105113
async def update_task(
106114
db_session: AsyncSession,
107115
task_id: asUUID,
@@ -131,6 +139,7 @@ async def update_task(
131139
flag_modified(task, "data")
132140

133141
await db_session.flush()
142+
await _sync_session_display_title(db_session, task.session_id)
134143
# Changes will be committed when the session context exits
135144
return Result.resolve(task)
136145

@@ -184,6 +193,7 @@ async def insert_task(
184193

185194
db_session.add(task)
186195
await db_session.flush()
196+
await _sync_session_display_title(db_session, session_id)
187197
return Result.resolve(task)
188198

189199

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import uuid
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
import pytest
5+
6+
from acontext_core.schema.result import Result
7+
from acontext_core.schema.orm import Task
8+
from acontext_core.service.data.task import insert_task, update_task
9+
10+
11+
MODULE = "acontext_core.service.data.task"
12+
13+
14+
class TestTaskTitleSync:
15+
@pytest.mark.asyncio
16+
async def test_insert_task_syncs_session_title_from_first_task(self):
17+
db_session = AsyncMock()
18+
db_session.add = MagicMock()
19+
db_session.execute = AsyncMock(return_value=MagicMock())
20+
db_session.flush = AsyncMock()
21+
project_id = uuid.uuid4()
22+
session_id = uuid.uuid4()
23+
24+
with (
25+
patch(
26+
f"{MODULE}.fetch_first_task_description",
27+
new_callable=AsyncMock,
28+
return_value=Result.resolve("First task title"),
29+
),
30+
patch(
31+
f"{MODULE}.SD.update_session_display_title_once",
32+
new_callable=AsyncMock,
33+
return_value=Result.resolve(True),
34+
) as update_title_mock,
35+
):
36+
result = await insert_task(
37+
db_session,
38+
project_id,
39+
session_id,
40+
after_order=0,
41+
data={"task_description": "First task title"},
42+
)
43+
44+
assert result.ok()
45+
update_title_mock.assert_awaited_once_with(
46+
db_session, session_id, "First task title"
47+
)
48+
49+
@pytest.mark.asyncio
50+
async def test_update_task_syncs_session_title_from_first_task(self):
51+
db_session = AsyncMock()
52+
task = Task(
53+
session_id=uuid.uuid4(),
54+
project_id=uuid.uuid4(),
55+
order=1,
56+
data={"task_description": "Old title"},
57+
status="pending",
58+
)
59+
query_result = MagicMock()
60+
query_result.scalars.return_value.first.return_value = task
61+
db_session.execute = AsyncMock(return_value=query_result)
62+
db_session.flush = AsyncMock()
63+
64+
with (
65+
patch(
66+
f"{MODULE}.fetch_first_task_description",
67+
new_callable=AsyncMock,
68+
return_value=Result.resolve("First task title"),
69+
),
70+
patch(
71+
f"{MODULE}.SD.update_session_display_title_once",
72+
new_callable=AsyncMock,
73+
return_value=Result.resolve(True),
74+
) as update_title_mock,
75+
):
76+
result = await update_task(
77+
db_session,
78+
task.id,
79+
patch_data={"task_description": "New title"},
80+
)
81+
82+
assert result.ok()
83+
update_title_mock.assert_awaited_once_with(
84+
db_session, task.session_id, "First task title"
85+
)
86+
87+
@pytest.mark.asyncio
88+
async def test_skips_title_write_when_first_task_title_is_missing(self):
89+
db_session = AsyncMock()
90+
db_session.add = MagicMock()
91+
db_session.execute = AsyncMock(return_value=MagicMock())
92+
db_session.flush = AsyncMock()
93+
94+
with (
95+
patch(
96+
f"{MODULE}.fetch_first_task_description",
97+
new_callable=AsyncMock,
98+
return_value=Result.resolve(None),
99+
),
100+
patch(
101+
f"{MODULE}.SD.update_session_display_title_once",
102+
new_callable=AsyncMock,
103+
return_value=Result.resolve(True),
104+
) as update_title_mock,
105+
):
106+
result = await insert_task(
107+
db_session,
108+
uuid.uuid4(),
109+
uuid.uuid4(),
110+
after_order=0,
111+
data={"task_description": "First task title"},
112+
)
113+
114+
assert result.ok()
115+
update_title_mock.assert_not_awaited()

0 commit comments

Comments
 (0)