diff --git a/data/card_translation_dictionary.csv b/data/card_translation_dictionary.csv index 00daed4..e3022bf 100644 --- a/data/card_translation_dictionary.csv +++ b/data/card_translation_dictionary.csv @@ -1,4 +1,7 @@ id,category,english,localized,inspected +-lUSHwHPs0,action,Drew,그렸어요,False +qn-eVMVboh,action,Met,만났어요,False +T-w6AqR2WI,action,Wrote,썼어요,False AtpX8pHl4M,action,after school,학교 끝나고,False Z7PKuepeFz,action,apologize,사과해요,False 9xqfhNC0iY,action,argue,논해요,False @@ -7,11 +10,13 @@ rFP_f6kugO,action,ask,물어봐요,False S5ybpAoUkJ,action,at lunch,점심 때,False C981hmNoSM,action,ate,먹었어요,True PcgcuBvG2g,action,attacked,공격했어요,False +WuY3NIdVc7,action,bored,지루해요,False J3qH5TLi_n,action,bring,가져가요,True QYBk1Wh9mw,action,built,지었어요,True Bir4yNufTz,action,buy,사요,True 9ApkXNWhGk,action,chat,잡담해요,True Z6-RUtuXwp,action,choose,선택해요,True +Y8B-lSXI_c,action,climb,올라가요,False r35xvR3VnP,action,climbed,올라갔어요,True f7x6vYZGZH,action,color,색칠해요,True eSlLLkKkpf,action,comfort,편안해요,False @@ -21,6 +26,7 @@ c9nrRATbZC,action,create,만들어요,True 3xqmUR6WRo,action,created,만들었어요,True ZM7wuj5mEf,action,cry,울어요,False n-Tf8mfwDt,action,dance,춤춰요,True +La-iP5y-_Y,action,describe,설명해요,False O1Z_dMAq4X,action,did,했어요,True qCj-Koppsp,action,discuss,토의해요,True DWKV6w20jL,action,dislike,싫어요,True @@ -35,6 +41,7 @@ qFMNNFSCX5,action,eat,먹어요,True H67n9i7nOg,action,eating,먹어요,True voBXCU3PyM,action,enjoy,즐겨요,True bFnGzqF9Yj,action,enjoyed,즐겼어요,True +eABl8ClFz9,action,excited,신나요,False eM3DY40FFi,action,explain,설명해요,True lnHBypcdND,action,explore,탐험해요,False a3KelxlGJh,action,feel,느껴요,True @@ -48,6 +55,7 @@ dcBKqBvDJT,action,forgive,용서해요,True FG6t-MIGTa,action,forgot,잊어버렸어요,False -urVCbaa_L,action,go,가요,True O6u0XAv7Vm,action,had,했어요,True +5y7kANN0Oi,action,happy,행복해요,False IIfEakpYmR,action,have,가져요,True SQOjY1hhrK,action,help,도와줘요,True LHl8HjriW6,action,helped,도와주었어요,True @@ -75,6 +83,7 @@ FKx7TtcCcr,action,listened,들었어요,True siwWKm9BV8,action,look,보아요,True QHErJpKn02,action,lost,잃어버렸어요,False 6aCL61BwoT,action,love,사랑해요,True +K6c6-F2e8w,action,made,만들었어요,False eFapnHTSZ-,action,make,만들어요,True CehJbDpG_e,action,meet,만나요,True D5Pq-2-sTX,action,met,만났어요,True @@ -100,6 +109,7 @@ RkGMYue-3T,action,sang,노래했어요,True SvdWDngshX,action,saw,봤어요,True CmuDetIOQk,action,say,말해요,False 0vELrai42a,action,scare,무서워요,False +WdrrGIWji7,action,scared,무서워요,False WKb3RbrCmU,action,see,보아요,True 1N_POFEXle,action,share,함께 나누어요,True AvJsfa28_0,action,share with,공유해요,False @@ -132,12 +142,14 @@ ssoybtBkSf,action,tease,놀려요,False SVk91PDyct,action,tell,이야기해요,True eQZL7MHvE9,action,threw,던졌어요,True 7iZI-C4C66,action,throw,던져요,True +5M-KuPT-LW,action,tired,피곤해요,False nLu2kQrCgk,action,try,시도해요,True hpsevNf_wR,action,understand,이해해요,True O68F6a00VU,action,upset,화나요,False gtjJfAAdum,action,use,사용해요,True 3w7mNezvPA,action,visit,방문해요,True EWpMoIaJI9,action,want,원해요,True +6luWwXtp1D,action,wanted,원했어요,False AOIFIQ4lZv,action,was boring,지루했어요,False OECWt0H5PH,action,was fun,즐거웠어요,False iLeKwAROSy,action,was hard,어려웠어요,False @@ -168,6 +180,7 @@ yvtJ2-R-Dd,emotion,concentrated,집중해요,True XNDF0_wSwd,emotion,content,만족해요,True mh-UBb8kM7,emotion,curious,궁금해요,True wrcjq2faU1,emotion,defiant,저항해요,False +A3VbaDJAAi,emotion,delicious,맛있어요,False 91Ex6R4Ws0,emotion,difficult,어려워요,True A3qnncaY1q,emotion,disappointed,실망했어요,True shSNzAio8H,emotion,dislike,싫어해요,True @@ -218,6 +231,7 @@ E3fWcTGiDH,emotion,sad,슬퍼요,True VNwYfr00ga,emotion,shy,부끄러워요,True hDt7NLgf2u,emotion,simple,간단해요,True 86201LBWef,emotion,sorry,미안해요,True +uamsWkkXKf,emotion,stressed,스트레스받아요,False 2X0JOPBeuS,emotion,surprised,놀랐어요,True kpnbdnPwqE,emotion,tired,피곤해요,True OCaMDYq79-,emotion,tiring,피곤해요,True @@ -246,6 +260,7 @@ tEDMBJYVR8,topic,blocks,블록,True rwvFdtPeH0,topic,book,책,True 6dkvootrxa,topic,break,휴식,True SPH_0gDzXd,topic,break time,쉬는 시간,True +LsJBeFwtmA,topic,breaktime,쉬는 시간,False 2DNqirPt0_,topic,car,자동차,False abPVau7epK,topic,cartoon,만화,True ucBETq0Hhp,topic,cartoons,만화,True @@ -255,6 +270,7 @@ lanXgQ4kc9,topic,classes,수업,True uK0UmLUNHi,topic,classmate,친구,True IKFZpU4ECQ,topic,classmates,동급생,True L5n_Q6xRVF,topic,classroom,교실,True +uG4JKN6TSL,topic,classroom activity,교실 활동,False 63BIuiTJAL,topic,color,색깔,True ZMzrVSxPQy,topic,colors,색깔,True ZqQmq0jgXj,topic,conversation,대화,True @@ -278,6 +294,7 @@ XYd7spA6_e,topic,food,음식,True LOPwB6gP7U,topic,football,축구,True bXfu9JuvvL,topic,friend,친구,True FzmfcoAijg,topic,friends,친구,True +wYZ3hlqRaH,topic,fun,즐거움,False kIjPY5r2mK,topic,game,놀이,True HXUW6Aqul2,topic,games,게임,True yzbNoUa1xE,topic,gluing,붙이기,True @@ -294,7 +311,9 @@ yHMiG8vVr7,topic,lesson,수업,True KqMbwnuQTE,topic,lessons,수업,True 7HjNTrqSKY,topic,listen,듣기,True 4PgUQB51Ht,topic,lunch,점심,True +_0hjBhSJwK,topic,lunchtime,점심 시간,False Vw7mR4BXYQ,topic,math,수학,True +-Q6cagIwqO,topic,math class,수학 수업,False vMmv5GwdN7,topic,meal,식사,True 92wk3FeCUg,topic,mom,엄마,True 0IK-m4gxPa,topic,movie,영화,True @@ -345,6 +364,7 @@ ymcbqCiwH-,topic,songs,노래,False Ddl43DNFUp,topic,sports,스포츠,True lnpE__vNu7,topic,story,이야기,True sX6NvIW7H0,topic,study,공부,True +6JyUR7bg_T,topic,studying,공부,False Oct0H6k8G7,topic,subject,과목,True rzQIL0Zu5b,topic,subjects,과목,False 3L58S0C71G,topic,swing,그네,True @@ -352,6 +372,7 @@ rjRieEWi9P,topic,talk,이야기,True 53eLBLSTuk,topic,teacher,선생님,True 8T9a-E4F7B,topic,theater,극장,True fwevuioC5m,topic,tickets,티켓,True +Cvu5PkRqny,topic,time,시간,False UhU29qrEDh,topic,together,함께,True 05joagL419,topic,toy,장난감,True pt-tfZEEK4,topic,toys,장난감,True diff --git a/libs/py_core/py_core.iml b/libs/py_core/py_core.iml index 5b0f629..9faa5f5 100644 --- a/libs/py_core/py_core.iml +++ b/libs/py_core/py_core.iml @@ -2,8 +2,10 @@ - - + + + + \ No newline at end of file diff --git a/libs/py_core/py_core/cli.py b/libs/py_core/py_core/cli.py index faebfe1..27bbaf7 100644 --- a/libs/py_core/py_core/cli.py +++ b/libs/py_core/py_core/cli.py @@ -2,7 +2,8 @@ from chatlib.utils.validator import make_non_empty_string_validator from py_core import ModeratorSession -from py_core.system.model import ChildCardRecommendationResult, ParentGuideRecommendationResult, CardInfo, DialogueRole +from py_core.system.model import ChildCardRecommendationResult, ParentGuideRecommendationResult, CardInfo, DialogueRole, \ + ParentGuideType, ParentExampleMessage async def test_session_loop(session: ModeratorSession): @@ -15,12 +16,33 @@ async def test_session_loop(session: ModeratorSession): if current_parent_guide_recommendation_result is not None: print(current_parent_guide_recommendation_result.model_dump_json(indent=2)) + if len(current_parent_guide_recommendation_result.messaging_guides) > 0: + while True: + enter_parent_message = "Enter parent message" + choices = [] + choices.extend([f"Show example message for \"{guide.guide_localized}\"" for guide in + current_parent_guide_recommendation_result.messaging_guides]) + choices.append(enter_parent_message) + selection = await questionary.select( + message="Choose an option.", + choices=choices, + default="Enter parent message" + ).ask_async() + + if choices.index(selection) < len(choices) - 1: + guide = current_parent_guide_recommendation_result.messaging_guides[choices.index(selection)] + example_message: ParentExampleMessage = await session.request_parent_example_message( + current_parent_guide_recommendation_result.id, guide_id=guide.id) + questionary.print(f"\"{example_message.message_localized}\" ({example_message.message})", style="bold italic fg:green") + else: + break parent_message = await questionary.text(": ", default="오늘 학교에서 뭐 했니?" if current_parent_guide_recommendation_result is None and current_card_recommendation_result is None else "", validate=make_non_empty_string_validator( "A message should not be empty."), qmark="*").ask_async() - current_card_recommendation_result = await session.submit_parent_message(parent_message, current_parent_guide_recommendation_result) + current_card_recommendation_result = await session.submit_parent_message(parent_message, + current_parent_guide_recommendation_result) current_interim_cards.clear() continue @@ -34,11 +56,11 @@ async def test_session_loop(session: ModeratorSession): if submittable: choices += ["[Submit]"] - selection = await questionary.select(**{ - 'choices': choices, - 'default': '[Refresh cards]', - 'message': f'Choose a word card. {"" if current_interim_cards is None or len(current_interim_cards) == 0 else ("Current selection: " + ", ".join([card.simple_str() for card in current_interim_cards]))}...' - }).ask_async() + selection = await questionary.select( + choices=choices, + default='[Refresh cards]', + message=f'Choose a word card. {"" if current_interim_cards is None or len(current_interim_cards) == 0 else ("Current selection: " + ", ".join([card.simple_str() for card in current_interim_cards]))}...' + ).ask_async() if choices.index(selection) == 0: # refresh diff --git a/libs/py_core/py_core/system/guide_categories.py b/libs/py_core/py_core/system/guide_categories.py new file mode 100644 index 0000000..c1b42d3 --- /dev/null +++ b/libs/py_core/py_core/system/guide_categories.py @@ -0,0 +1,78 @@ +from enum import StrEnum +from functools import cache +from typing import Optional + +from pydantic import BaseModel + + +class CategoryWithDescription(BaseModel): + label: str + description: str + min_turns: Optional[int] = None # Min number of messages in dialogue to activate this category + +class DialogueInspectionCategory(StrEnum): + Blame = "blame" + Correction = "correction" + Complex = "complex" + Deviation = "deviation" + + def __init__(self, value): + self.description: str = { + "blame": "When the parent criticizes or negatively evaluates the child's responds, like reprimanding or scolding", + "correction": "When the parent is compulsively correcting the child's response or pointing out that the child is wrong", + "complex": "When a parent's dialogue contains more than one goal or intent", + "deviation": "When both parent and child stray from the main topic of the conversation" + }[value] + + min_turns_table = { + "deviation": 5 + } + + self.inspection_min_turns: int | None = min_turns_table[value] if value in min_turns_table else None + + @classmethod + @cache + def values_with_desc(cls) -> list[CategoryWithDescription]: + return list(map(lambda c: CategoryWithDescription(label=c.value, description=c.description, min_turns=c.inspection_min_turns), cls)) + + +class ParentGuideCategory(StrEnum): + Intention="intention" + Specification="specification" + Choice="choice" + Clues="clues" + Coping="coping" + Stimulate="stimulate" + Share="share" + Empathize="empathize" + Encourage="encourage" + Emotion="emotion" + Extend="extend" + Terminate="terminate" + + def __init__(self, value): + self.description={ + "intention": "Check the intention behind the child’s response and ask back", + "specification": "Ask about \"what\" to specify the event", + "choice": "Provide choices for children to select their answers", + "clues": "Give clues that can be answered based on previously known information", + "coping": "Suggest coping strategies for specific situations to the child", + "stimulate": "Present information that contradicts what is known to stimulate the child's interest", + "share": "Share the parent's emotions and thoughts in simple language", + "empathize": "Empathize with the child's feelings", + "encourage": "Encourage the child's actions or emotions", + "emotion": "Asking about the child's feelings and emotions", + "extend": "Inducing an expansion or change of the conversation topic", + "terminate": "Inquiring about the desire to end the conversation" + }[value] + + min_turns_table = { + "terminate": 5 + } + + self.active_min_turns: int | None = min_turns_table[value] if value in min_turns_table else None + + @classmethod + @cache + def values_with_desc(cls) -> list[CategoryWithDescription]: + return list(map(lambda c: CategoryWithDescription(label=c.value, description=c.description, min_turns=c.active_min_turns), cls)) diff --git a/libs/py_core/py_core/system/model.py b/libs/py_core/py_core/system/model.py index d6c5e5f..db6b8c5 100644 --- a/libs/py_core/py_core/system/model.py +++ b/libs/py_core/py_core/system/model.py @@ -1,10 +1,13 @@ from enum import StrEnum +from functools import cached_property from typing import TypeAlias, Optional from chatlib.utils.time import get_timestamp from nanoid import generate from pydantic import BaseModel, ConfigDict, TypeAdapter, Field +from py_core.system.guide_categories import ParentGuideCategory + def id_generator() -> str: return generate(size=20) @@ -29,7 +32,7 @@ class CardCategory(StrEnum): class CardInfo(CardIdentity): - model_config = ConfigDict(frozen=True) + model_config = ConfigDict(frozen=True, use_enum_values=True) text: str = Field() localized: str @@ -58,29 +61,49 @@ class ParentGuideType(StrEnum): class ParentGuideElement(BaseModel): - model_config = ConfigDict(frozen=True) + model_config = ConfigDict(frozen=True, use_enum_values=True) - category: str | None + id: str = Field(default_factory=lambda: generate(size=5)) + + category: ParentGuideCategory | None guide: str - guide_localized: Optional[str] =None + guide_localized: Optional[str] = None type: ParentGuideType = ParentGuideType.Messaging def with_guide_localized(self, localized: str) -> 'ParentGuideElement': return self.model_copy(update=dict(guide_localized=localized)) @classmethod - def messaging_guide(cls, category: str, guide: str) -> 'ParentGuideElement': + def messaging_guide(cls, category: ParentGuideCategory, guide: str) -> 'ParentGuideElement': return ParentGuideElement(category=category, guide=guide, type=ParentGuideType.Messaging) @classmethod - def feedback(cls, category: str | None, guide: str) -> 'ParentGuideElement': + def feedback(cls, category: ParentGuideCategory | None, guide: str) -> 'ParentGuideElement': return ParentGuideElement(category=category, guide=guide, type=ParentGuideType.Feedback) class ParentGuideRecommendationResult(ModelWithIdAndTimestamp): model_config = ConfigDict(frozen=True) - recommendations: list[ParentGuideElement] + guides: list[ParentGuideElement] + + @cached_property + def messaging_guides(self) -> list[ParentGuideElement]: + return [guide for guide in self.guides if guide.type == ParentGuideType.Messaging] + + @cached_property + def feedback_guides(self) -> list[ParentGuideElement]: + return [guide for guide in self.guides if guide.type == ParentGuideType.Feedback] + + +class ParentExampleMessage(ModelWithIdAndTimestamp): + model_config = ConfigDict(frozen=True) + + recommendation_id: str | None = None + guide_id: str | None = None + + message: str + message_localized: str | None = None class DialogueRole(StrEnum): diff --git a/libs/py_core/py_core/system/moderator.py b/libs/py_core/py_core/system/moderator.py index 10cd701..de26857 100644 --- a/libs/py_core/py_core/system/moderator.py +++ b/libs/py_core/py_core/system/moderator.py @@ -1,14 +1,18 @@ import asyncio +from dataclasses import dataclass + from nanoid import generate from py_core.system.model import ChildCardRecommendationResult, DialogueMessage, DialogueRole, CardInfo, \ CardIdentity, \ - ParentGuideRecommendationResult + ParentGuideRecommendationResult, Dialogue, ParentGuideType, ParentExampleMessage, ParentGuideElement from py_core.system.storage import SessionStorage from py_core.system.task import ChildCardRecommendationGenerator -from py_core.system.task.parent_guide_recommendation import ParentGuideRecommendationGenerator +from py_core.system.task.parent_guide_recommendation import ParentGuideRecommendationGenerator, \ + ParentExampleMessageGenerator from py_core.system.task.parent_guide_recommendation.dialogue_inspector import DialogueInspector from py_core.utils.deepl_translator import DeepLTranslator +from py_core.utils.models import AsyncTaskInfo def speaker(role: DialogueRole): @@ -27,6 +31,12 @@ async def wrapper(self: 'ModeratorSession', *args, **kwargs): return decorator +@dataclass +class ParentExampleGenerationTaskSet: + recommendation_id: str + tasks: dict[str, AsyncTaskInfo | None] + + class ModeratorSession: def __init__(self, storage: SessionStorage): @@ -40,18 +50,45 @@ def __init__(self, storage: SessionStorage): self.__deepl_translator = DeepLTranslator() self.__dialogue_inspector = DialogueInspector() - self.__dialogue_inspection_task: asyncio.Task | None = None - self.__dialogue_inspection_task_id: str | None = None + self.__dialogue_inspection_task_info: AsyncTaskInfo | None = None + + self.__parent_example_generation_tasks: ParentExampleGenerationTaskSet | None = None + + self.__parent_example_generator = ParentExampleMessageGenerator() @property def next_speaker(self) -> DialogueRole: return self.__next_speaker + def __clear_parent_example_generation_tasks(self): + if self.__parent_example_generation_tasks is not None: + for k, t in self.__parent_example_generation_tasks.tasks.items(): + if not t.task.done(): + t.task.cancel() + self.__parent_example_generation_tasks = None + + async def __parent_example_generate_func(self, dialogue: Dialogue, guide: ParentGuideElement, recommendation_id: str) -> ParentExampleMessage: + message = await self.__parent_example_generator.generate(dialogue, guide, recommendation_id) + await self.__storage.add_parent_example_message(message) + return message + + def __place_parent_example_generation_tasks(self, dialogue: Dialogue, recommendation: ParentGuideRecommendationResult): + self.__parent_example_generation_tasks = ParentExampleGenerationTaskSet( + recommendation_id=recommendation.id, + tasks={guide.id: AsyncTaskInfo( + task_id=guide.id, + task=asyncio.create_task(self.__parent_example_generate_func(dialogue, guide, recommendation.id)) + ) for guide in recommendation.guides if guide.type == ParentGuideType.Messaging} + ) + @speaker(DialogueRole.Parent) async def submit_parent_message(self, parent_message: str, current_parent_guide: ParentGuideRecommendationResult | None = None) -> ChildCardRecommendationResult: try: + # Clear if there is a pending example generation task. + self.__clear_parent_example_generation_tasks() + print("Translate parent message..") message_eng = await self.__deepl_translator.translate( text=parent_message, @@ -71,12 +108,15 @@ async def submit_parent_message(self, parent_message: str, dialogue = await self.__storage.get_dialogue() # Start a background task for inspection. - if self.__dialogue_inspection_task is not None: - self.__dialogue_inspection_task.cancel() + if self.__dialogue_inspection_task_info is not None: + self.__dialogue_inspection_task_info.task.cancel() - self.__dialogue_inspection_task_id = generate(size=5) + inspection_task_id = generate(size=5) - self.__dialogue_inspection_task = asyncio.create_task(self.__dialogue_inspector.inspect(dialogue, self.__dialogue_inspection_task_id)) + self.__dialogue_inspection_task_info = AsyncTaskInfo(task_id=inspection_task_id, + task=asyncio.create_task( + self.__dialogue_inspector.inspect(dialogue, + inspection_task_id))) recommendation = await self.__child_card_recommender.generate(dialogue, None, None) @@ -88,7 +128,6 @@ async def submit_parent_message(self, parent_message: str, except Exception as e: raise e - async def __get_card_info_from_identities(self, cards: list[CardIdentity] | list[CardInfo]) -> list[CardInfo]: cards = [card_identity if isinstance(card_identity, CardInfo) else ( await self.__storage.get_card_recommendation_result(card_identity.recommendation_id)).find_card_by_id( @@ -123,22 +162,44 @@ async def submit_child_selected_card(self, selected_cards: list[CardIdentity] | # Join a dialogue inspection task dialogue_inspection_result = None - if self.__dialogue_inspection_task_id is not None and self.__dialogue_inspection_task is not None: - dialogue_inspection_result, task_id = await self.__dialogue_inspection_task - if task_id != self.__dialogue_inspection_task_id: + if self.__dialogue_inspection_task_info is not None: + dialogue_inspection_result, task_id = await self.__dialogue_inspection_task_info.task + if task_id != self.__dialogue_inspection_task_info.task_id: dialogue_inspection_result = None # Clear - self.__dialogue_inspection_task_id = None - self.__dialogue_inspection_task = None + self.__dialogue_inspection_task_info = None recommendation = await self.__parent_guide_recommender.generate(dialogue, dialogue_inspection_result) await self.__storage.add_parent_guide_recommendation_result(recommendation) + # Invoke an example generation task in advance. + self.__clear_parent_example_generation_tasks() + self.__place_parent_example_generation_tasks(dialogue, recommendation) + self.__next_speaker = DialogueRole.Parent return recommendation except Exception as e: raise e + + @speaker(DialogueRole.Parent) + async def request_parent_example_message(self, recommendation_id: str, guide_id: str) -> ParentExampleMessage: + if (self.__parent_example_generation_tasks is not None + and self.__parent_example_generation_tasks.recommendation_id == recommendation_id): + example_message: ParentExampleMessage = await self.__parent_example_generation_tasks.tasks[guide_id].task + return example_message + else: + example_message: ParentExampleMessage = await self.__storage.get_parent_example_message(recommendation_id, + guide_id) + if example_message is not None: + return example_message + else: + dialogue = await self.__storage.get_dialogue() + recommendation = await self.__storage.get_parent_guide_recommendation_result(recommendation_id) + guide = [guide for guide in recommendation.guides if guide.id == guide_id][0] + example_message: ParentExampleMessage = await self.__parent_example_generate_func(dialogue, guide, recommendation.id) + return example_message + diff --git a/libs/py_core/py_core/system/storage/json.py b/libs/py_core/py_core/system/storage/json.py index ddf168c..ca8cd12 100644 --- a/libs/py_core/py_core/system/storage/json.py +++ b/libs/py_core/py_core/system/storage/json.py @@ -6,7 +6,7 @@ from os import path, getcwd, makedirs from py_core.system.model import ParentGuideRecommendationResult, ChildCardRecommendationResult, Dialogue, \ - DialogueMessage, DialogueTypeAdapter + DialogueMessage, DialogueTypeAdapter, ParentExampleMessage from py_core.system.storage import SessionStorage @@ -14,6 +14,7 @@ class SessionJsonStorage(SessionStorage): TABLE_MESSAGES = "messages" TABLE_CARD_RECOMMENDATIONS = "card_recommendations" TABLE_PARENT_RECOMMENDATIONS = "parent_recommendations" + TABLE_PARENT_EXAMPLE_MESSAGES = "parent_example_messages" def __init__(self, session_id: str | None = None): super().__init__(session_id) @@ -67,3 +68,16 @@ async def get_parent_guide_recommendation_result(self, return ParentGuideRecommendationResult(**result[0]) else: return None + + async def add_parent_example_message(self, message: ParentExampleMessage): + table = self.db.table(self.TABLE_PARENT_EXAMPLE_MESSAGES) + table.insert(message.model_dump()) + + async def get_parent_example_message(self, recommendation_id: str, guide_id: str) -> ParentExampleMessage | None: + table = self.db.table(self.TABLE_PARENT_EXAMPLE_MESSAGES) + q = Query() + result = table.search((q.recommendation_id == recommendation_id) & (q.guide_id == guide_id)) + if len(result) > 0: + return ParentExampleMessage(**result[0]) + else: + return None diff --git a/libs/py_core/py_core/system/storage/memory.py b/libs/py_core/py_core/system/storage/memory.py index 01e5257..ee98f93 100644 --- a/libs/py_core/py_core/system/storage/memory.py +++ b/libs/py_core/py_core/system/storage/memory.py @@ -1,11 +1,10 @@ from py_core.system.model import Dialogue, ParentGuideRecommendationResult, ChildCardRecommendationResult, \ - DialogueMessage + DialogueMessage, ParentExampleMessage from py_core.system.storage.session_storage import SessionStorage class SessionMemoryStorage(SessionStorage): - def __init__(self, session_id: str | None = None): super().__init__(session_id) @@ -13,6 +12,8 @@ def __init__(self, session_id: str | None = None): self.__parent_guide_recommendations: dict[str, ParentGuideRecommendationResult] = {} self.__card_recommendations: dict[str, ChildCardRecommendationResult] = {} + self.__parent_example_messages: dict[tuple[str, str], ParentExampleMessage] = {} + async def add_dialogue_message(self, message: DialogueMessage): self.__dialogue.append(message) @@ -37,3 +38,12 @@ async def get_parent_guide_recommendation_result(self, return self.__parent_guide_recommendations[recommendation_id] else: return None + + async def add_parent_example_message(self, message: ParentExampleMessage): + self.__parent_example_messages[(message.recommendation_id, message.guide_id)] = message + + async def get_parent_example_message(self, recommendation_id: str, guide_id: str) -> ParentExampleMessage | None: + if (recommendation_id, guide_id) in self.__parent_example_messages: + return self.__parent_example_messages[(recommendation_id, guide_id)] + else: + return None diff --git a/libs/py_core/py_core/system/storage/session_storage.py b/libs/py_core/py_core/system/storage/session_storage.py index af8131f..b477743 100644 --- a/libs/py_core/py_core/system/storage/session_storage.py +++ b/libs/py_core/py_core/system/storage/session_storage.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from py_core.system.model import ChildCardRecommendationResult, Dialogue, DialogueMessage, \ - ParentGuideRecommendationResult, id_generator + ParentGuideRecommendationResult, id_generator, ParentExampleMessage class SessionStorage(ABC): @@ -29,6 +29,10 @@ async def add_card_recommendation_result(self, result: ChildCardRecommendationRe async def add_parent_guide_recommendation_result(self, result: ParentGuideRecommendationResult): pass + @abstractmethod + async def add_parent_example_message(self, message: ParentExampleMessage): + pass + @abstractmethod async def get_card_recommendation_result(self, recommendation_id: str)->ChildCardRecommendationResult | None: pass @@ -37,5 +41,7 @@ async def get_card_recommendation_result(self, recommendation_id: str)->ChildCar async def get_parent_guide_recommendation_result(self, recommendation_id: str)->ParentGuideRecommendationResult | None: pass - + @abstractmethod + async def get_parent_example_message(self, recommendation_id: str, guide_id: str) -> ParentExampleMessage | None: + pass diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/__init__.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/__init__.py index 3661ea4..7b627d5 100644 --- a/libs/py_core/py_core/system/task/parent_guide_recommendation/__init__.py +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/__init__.py @@ -1 +1,2 @@ -from .generator import ParentGuideRecommendationGenerator +from .guide_generator import ParentGuideRecommendationGenerator +from .example_generator import ParentExampleMessageGenerator diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/common.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/common.py index eba3bd7..9861718 100644 --- a/libs/py_core/py_core/system/task/parent_guide_recommendation/common.py +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/common.py @@ -1,88 +1,12 @@ -from enum import StrEnum -from functools import cache -from typing import TypeAlias, Optional +from typing import TypeAlias from pydantic import BaseModel +from py_core.system.guide_categories import DialogueInspectionCategory from py_core.system.model import ParentGuideElement ParentGuideRecommendationAPIResult: TypeAlias = list[ParentGuideElement] -class CategoryWithDescription(BaseModel): - label: str - description: str - min_turns: Optional[int] = None # Min number of messages in dialogue to activate this category - -class DialogueInspectionCategory(StrEnum): - Blame = "blame" - Correction = "correction" - Complex = "complex" - Deviation = "deviation" - - def __init__(self, value): - self.description: str = { - "blame": "When the parent criticizes or negatively evaluates the child's responds, like reprimanding or scolding", - "correction": "When the parent is compulsively correcting the child's response or pointing out that the child is wrong", - "complex": "When a parent's dialogue contains more than one goal or intent", - "deviation": "When both parent and child stray from the main topic of the conversation" - }[value] - - min_turns_table = { - "deviation": 5 - } - - self.inspection_min_turns: int | None = min_turns_table[value] if value in min_turns_table else None - - @classmethod - @cache - def values_with_desc(cls) -> list[CategoryWithDescription]: - return list(map(lambda c: CategoryWithDescription(label=c.value, description=c.description, min_turns=c.inspection_min_turns), cls)) - - -class ParentGuideCategory(StrEnum): - Intention="intention" - Specification="specification" - Choice="choice" - Clues="clues" - Coping="coping" - Stimulate="stimulate" - Share="share" - Empathize="empathize" - Encourage="encourage" - Emotion="emotion" - Extend="extend" - Terminate="terminate" - - def __init__(self, value): - self.description={ - "intention": "Check the intention behind the child’s response and ask back", - "specification": "Ask about \"what\" to specify the event", - "choice": "Provide choices for children to select their answers", - "clues": "Give clues that can be answered based on previously known information", - "coping": "Suggest coping strategies for specific situations to the child", - "stimulate": "Present information that contradicts what is known to stimulate the child's interest", - "share": "Share the parent's emotions and thoughts in simple language", - "empathize": "Empathize with the child's feelings", - "encourage": "Encourage the child's actions or emotions", - "emotion": "Asking about the child's feelings and emotions", - "extend": "Inducing an expansion or change of the conversation topic", - "terminate": "Inquiring about the desire to end the conversation" - }[value] - - min_turns_table = { - "terminate": 5 - } - - self.active_min_turns: int | None = min_turns_table[value] if value in min_turns_table else None - - @classmethod - @cache - def values_with_desc(cls) -> list[CategoryWithDescription]: - return list(map(lambda c: CategoryWithDescription(label=c.value, description=c.description, min_turns=c.active_min_turns), cls)) - - - - class DialogueInspectionResult(BaseModel): categories: list[DialogueInspectionCategory] diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/dialogue_inspector.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/dialogue_inspector.py index 0eb6eb5..59efede 100644 --- a/libs/py_core/py_core/system/task/parent_guide_recommendation/dialogue_inspector.py +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/dialogue_inspector.py @@ -6,9 +6,9 @@ MapperInputOutputPair from chatlib.llm.integration import GPTChatCompletionAPI, ChatGPTModel +from py_core.system.guide_categories import DialogueInspectionCategory from py_core.system.model import Dialogue, DialogueMessage, DialogueRole, CardCategory -from py_core.system.task.parent_guide_recommendation.common import DialogueInspectionResult, \ - DialogueInspectionCategory +from py_core.system.task.parent_guide_recommendation.common import DialogueInspectionResult from py_core.system.task.stringify import DialogueToStrConversionFunction _EXAMPLES = [ diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/example_generator.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/example_generator.py new file mode 100644 index 0000000..8098ede --- /dev/null +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/example_generator.py @@ -0,0 +1,68 @@ +from time import perf_counter + +from chatlib.llm.integration import GPTChatCompletionAPI, ChatGPTModel +from chatlib.tool.converter import str_to_str_noop +from chatlib.utils.jinja_utils import convert_to_jinja_template +from chatlib.tool.versatile_mapper import ChatCompletionFewShotMapperParams, ChatCompletionFewShotMapper +from pydantic import BaseModel + +from py_core.system.model import ParentGuideElement, ParentExampleMessage, Dialogue +from py_core.system.task.parent_guide_recommendation.example_translator import ParentExampleMessageTranslator +from py_core.system.task.stringify import DialogueToStrConversionFunction + + +class ParentExampleMessageGenerationInput(BaseModel): + dialogue: Dialogue + guide: ParentGuideElement + +_dialogue_to_str = DialogueToStrConversionFunction() + +def _convert_input_to_str(input: ParentExampleMessageGenerationInput, params)->str: + return f"""{_dialogue_to_str(input.dialogue, params)} + {input.guide.guide} + """ + + +_prompt_template = convert_to_jinja_template(""" +You are a helpful assistant who helps facilitate communication between minimally verbal autistic children and their parents. The goal of their conversation is to help the child and parent elaborate on a topic together. +[Task] Given a dialogue between a parent and a child and a message generation guide, suggest an example utterance that the parent can respond to the child's last message. + +[Input] +: The current state of the conversation +: A guidance for example utterance generation + +[Output] +A text string containing an utterance of the parent that complies with the message generation guide. +""") + +def _prompt_generator(input: ParentExampleMessageGenerationInput, params) -> str: + return _prompt_template.render() + +class ParentExampleMessageGenerator: + def __init__(self): + api = GPTChatCompletionAPI() + api.config().verbose = False + + self.__mapper: ChatCompletionFewShotMapper[ + ParentExampleMessageGenerationInput, + str, + ChatCompletionFewShotMapperParams] = ChatCompletionFewShotMapper( + api, + instruction_generator=_prompt_generator, + input_str_converter=_convert_input_to_str, + output_str_converter=str_to_str_noop, + str_output_converter=str_to_str_noop + ) + + self.__translator = ParentExampleMessageTranslator() + + async def generate(self, dialogue: Dialogue, guide: ParentGuideElement, recommendation_id: str) -> ParentExampleMessage: + + t_start = perf_counter() + utterance = await self.__mapper.run(None, ParentExampleMessageGenerationInput(dialogue=dialogue, guide=guide), + ChatCompletionFewShotMapperParams(model=ChatGPTModel.GPT_4_0613, api_params={})) + translated_utterance = await self.__translator.translate_example(utterance) + t_end = perf_counter() + # print(f"Example generation took {t_end - t_start} sec - {utterance} ({guide.category} - {guide.guide})") + + return ParentExampleMessage(recommendation_id=recommendation_id, guide_id=guide.id, message=utterance, message_localized=translated_utterance) diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/example_translator.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/example_translator.py index dca900b..d67e7ba 100644 --- a/libs/py_core/py_core/system/task/parent_guide_recommendation/example_translator.py +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/example_translator.py @@ -1,58 +1,27 @@ -import asyncio import re -from time import perf_counter from chatlib.llm.integration import GPTChatCompletionAPI, ChatGPTModel -from chatlib.tool.versatile_mapper import ChatCompletionFewShotMapper, ChatCompletionFewShotMapperParams +from chatlib.tool.versatile_mapper import ChatCompletionFewShotMapper, ChatCompletionFewShotMapperParams, \ + MapperInputOutputPair from chatlib.utils.jinja_utils import convert_to_jinja_template +from time import perf_counter from py_core.config import AACessTalkConfig -from py_core.system.model import ParentGuideElement from py_core.system.shared import vector_db from py_core.system.task.parent_guide_recommendation.common import ParentGuideRecommendationAPIResult -from py_core.utils.deepl_translator import DeepLTranslator from py_core.utils.lookup_translator import LookupTranslator from py_core.utils.models import DictionaryRow - -def convert_messages_to_xml(messages: list[str], params) -> str: - content = "\n".join([f" {msg}" for i, msg in enumerate(messages)]) - return f""" -{content} -""" - - -msg_regex = re.compile(r'(.*?)') - - -def convert_xml_to_messages(xml: str, params=None) -> list[str]: - matches = msg_regex.findall(xml) - return matches - - -template = convert_to_jinja_template("""The following XML list contains a list of messages for a parent talking with their child with ASD. -Translate the following messages into Korean. -Note that the messages are intended to be spoken by parent to a kid. -Don't use honorific form of Korean. - -{%-if samples is not none and samples | length > 0 %} - -Input: -{{stringify(samples | map(attribute='english'), none)}} - -Output: -{{stringify(samples | map(attribute='localized'), none)}} -{%-endif-%} +template = convert_to_jinja_template("""You are a helpful translator who translates an utterance of a parent talking with their child with ASD. +[Task] +- Translate the following English message to Korean. +- Note that the messages are intended to be spoken by parent to a kid. +- Don't use honorific form of Korean. """) -class ParentGuideExampleTranslationParams(ChatCompletionFewShotMapperParams): - samples: list[DictionaryRow] - - -def _generate_prompt(input, params: ParentGuideExampleTranslationParams) -> str: - r = template.render(samples=params.samples, stringify=convert_messages_to_xml) - print(r) +def _generate_prompt(input, params) -> str: + r = template.render() return r @@ -67,30 +36,23 @@ def __init__(self): vector_db=vector_db, verbose=True) - self.__example_translator: ChatCompletionFewShotMapper[ - list[str], list[str], ParentGuideExampleTranslationParams] = ChatCompletionFewShotMapper(api, - instruction_generator=_generate_prompt, - input_str_converter=convert_messages_to_xml, - output_str_converter=convert_messages_to_xml, - str_output_converter=convert_xml_to_messages - ) + self.__example_translator: ChatCompletionFewShotMapper[str, str, ChatCompletionFewShotMapperParams] = ChatCompletionFewShotMapper.make_str_mapper(api, + instruction_generator=_generate_prompt) - async def __translate_examples(self, examples: list[str]) -> list[str]: + async def translate_example(self, original_message: str) -> str: t_start = perf_counter() - samples = self.__dictionary.query_similar_rows(examples, None, k=5) + samples = self.__dictionary.query_similar_rows(original_message, None, k=3) + + samples_formatted = [ + MapperInputOutputPair(input=sample.english, output=sample.localized) for sample in samples + ] - result = await self.__example_translator.run(None, examples, ParentGuideExampleTranslationParams( - api_params={}, model=ChatGPTModel.GPT_3_5_0613, samples=samples)) + result = await self.__example_translator.run(samples_formatted, original_message, ChatCompletionFewShotMapperParams( + api_params={}, model=ChatGPTModel.GPT_3_5_0613)) t_end = perf_counter() - print(f"LLM translation took {t_end - t_start} sec.") + # print(f"LLM translation took {t_end - t_start} sec.") return result - - async def translate(self, api_result: ParentGuideRecommendationAPIResult) -> ParentGuideRecommendationAPIResult: - examples = [entry.example for entry in api_result] - - translated_examples = await self.__translate_examples(examples) - pass diff --git a/libs/py_core/py_core/system/task/parent_guide_recommendation/generator.py b/libs/py_core/py_core/system/task/parent_guide_recommendation/guide_generator.py similarity index 97% rename from libs/py_core/py_core/system/task/parent_guide_recommendation/generator.py rename to libs/py_core/py_core/system/task/parent_guide_recommendation/guide_generator.py index 9213396..d406fad 100644 --- a/libs/py_core/py_core/system/task/parent_guide_recommendation/generator.py +++ b/libs/py_core/py_core/system/task/parent_guide_recommendation/guide_generator.py @@ -5,10 +5,11 @@ from chatlib.utils.jinja_utils import convert_to_jinja_template from time import perf_counter +from py_core.system.guide_categories import ParentGuideCategory from py_core.system.model import ParentGuideRecommendationResult, Dialogue, ParentGuideElement, DialogueMessage, \ CardCategory from py_core.system.task.parent_guide_recommendation.common import ParentGuideRecommendationAPIResult, \ - DialogueInspectionResult, DialogueInspectionCategory, ParentGuideCategory + DialogueInspectionResult from py_core.system.task.parent_guide_recommendation.guide_translator import GuideTranslator from py_core.system.task.stringify import DialogueToStrConversionFunction @@ -120,4 +121,4 @@ async def generate(self, dialogue: Dialogue, inspection_result: DialogueInspecti t_end = perf_counter() print(f"Translation took {t_end - t_trans} sec.") print(f"Total latency: {t_end - t_start} sec.") - return ParentGuideRecommendationResult(recommendations=translated_guide_list) + return ParentGuideRecommendationResult(guides=translated_guide_list) diff --git a/libs/py_core/py_core/utils/models.py b/libs/py_core/py_core/utils/models.py index 2a0f562..6ccbf72 100644 --- a/libs/py_core/py_core/utils/models.py +++ b/libs/py_core/py_core/utils/models.py @@ -1,3 +1,5 @@ +import asyncio +from dataclasses import dataclass from functools import cached_property from typing import Optional @@ -56,3 +58,8 @@ def empty_str_to_none(cls, v: str) -> str | None: else: return v + +@dataclass +class AsyncTaskInfo: + task: asyncio.Task + task_id: str diff --git a/libs/py_core/py_core/utils/vector_db.py b/libs/py_core/py_core/utils/vector_db.py index 95538b9..7727d6a 100644 --- a/libs/py_core/py_core/utils/vector_db.py +++ b/libs/py_core/py_core/utils/vector_db.py @@ -53,7 +53,7 @@ def upsert(self, collection: str | Collection, dictionary_row: DictionaryRow | l ) def query_similar_rows(self, collection: str | Collection, word: str | list[str], category: str | None, k: int = 5) -> list[DictionaryRow]: - print(f"Query similar cards: {word}, {category}") + #print(f"Query similar cards: {word}, {category}") collection_instance = (collection if isinstance(collection, Collection) else self.get_collection(collection)) query_result = collection_instance.query(query_texts=[word] if isinstance(word, str) else word, n_results=k, where={"category": category} if category is not None else None)