diff --git a/db/models.py b/db/models.py index 8f49b0d9..47fe6b84 100755 --- a/db/models.py +++ b/db/models.py @@ -31,7 +31,7 @@ from datetime import datetime from sqlalchemy import text from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm import relationship, backref, Session from sqlalchemy import ( Column, Integer, @@ -836,3 +836,41 @@ def __repr__(self): return "QueryClientData(client_id='{0}', created='{1}', modified='{2}', key='{3}', data='{4}')".format( self.client_id, self.created, self.modified, self.key, self.data ) + + +class DialogueData(Base): + """Represents dialogue data for a given client.""" + + __tablename__ = "dialoguedata" + + __table_args__ = ( + PrimaryKeyConstraint("client_id", "dialogue_key", name="dialoguedata_pkey"), + ) + + client_id = Column(String(256), nullable=False) + + # Dialogue key/name to distinguish between different dialogues that can be stored + dialogue_key = Column(String(64), nullable=False) + + # Created timestamp + created = Column(DateTime, nullable=False) + + # Last modified timestamp + modified = Column(DateTime, nullable=False) + + # JSON data + data = Column(JSONB, nullable=False) + + # Expires at timestamp + expires_at = Column(DateTime, nullable=False) + + def __repr__(self): + return "DialogueData(client_id='{0}', created='{1}', modified='{2}', dialogue_key='{3}', data='{4}', expires_at='{5}')".format( + self.client_id, + self.created, + self.modified, + self.dialogue_key, + self.data, + self.expires_at, + ) + diff --git a/queries/arithmetic.py b/queries/arithmetic.py index fa58e856..2a9f71ac 100755 --- a/queries/arithmetic.py +++ b/queries/arithmetic.py @@ -48,7 +48,7 @@ from queries import AnswerTuple, ContextDict, Query, QueryStateDict from queries.util import iceformat_float, gen_answer, read_grammar_file -from tree import Result, Node, TerminalNode +from tree import Result, Node, TerminalNode, ParamList from speech.trans import gssml @@ -315,7 +315,7 @@ def terminal_num(t: Optional[Result]) -> Optional[Union[str, int, float]]: return None -def QArNumberWord(node: Node, params: QueryStateDict, result: Result) -> None: +def QArNumberWord(node: Node, params: ParamList, result: Result) -> None: result._canonical = result._text if "context_reference" in result or "error_context_reference" in result: # Already pushed the context reference @@ -328,11 +328,11 @@ def QArNumberWord(node: Node, params: QueryStateDict, result: Result) -> None: add_num(result._nominative, result) -def QArOrdinalWord(node: Node, params: QueryStateDict, result: Result) -> None: +def QArOrdinalWord(node: Node, params: ParamList, result: Result) -> None: add_num(result._canonical, result) -def QArFractionWord(node: Node, params: QueryStateDict, result: Result) -> None: +def QArFractionWord(node: Node, params: ParamList, result: Result) -> None: fn = result._canonical.lower() fp = _FRACTION_WORDS.get(fn) if not fp: @@ -344,13 +344,13 @@ def QArFractionWord(node: Node, params: QueryStateDict, result: Result) -> None: result.frac_desc = fn # Used in voice answer -def QArMultOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArMultOperator(node: Node, params: ParamList, result: Result) -> None: """'tvisvar_sinnum', 'þrisvar_sinnum', 'fjórum_sinnum'""" add_num(result._nominative, result) result.op = "multiply" -def QArLastResult(node: Node, params: QueryStateDict, result: Result) -> None: +def QArLastResult(node: Node, params: ParamList, result: Result) -> None: """Reference to previous result, usually via the words 'það' or 'því' ('Hvað er það sinnum sautján?')""" q = result.state.get("query") @@ -364,45 +364,43 @@ def QArLastResult(node: Node, params: QueryStateDict, result: Result) -> None: result.context_reference = True -def QArPlusOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArPlusOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "plus" -def QArSumOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArSumOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "plus" -def QArMinusOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArMinusOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "minus" -def QArDivisionOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArDivisionOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "divide" -def QArMultiplicationOperator( - node: Node, params: QueryStateDict, result: Result -) -> None: +def QArMultiplicationOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "multiply" -def QArSquareRootOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArSquareRootOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "sqrt" -def QArPowOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArPowOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "pow" -def QArPercentOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArPercentOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "percent" -def QArFractionOperator(node: Node, params: QueryStateDict, result: Result) -> None: +def QArFractionOperator(node: Node, params: ParamList, result: Result) -> None: result.op = "fraction" -def Prósenta(node: Node, params: QueryStateDict, result: Result) -> None: +def Prósenta(node: Node, params: ParamList, result: Result) -> None: # Find percentage terminal d = result.find_descendant(t_base="prósenta") if d: @@ -412,7 +410,7 @@ def Prósenta(node: Node, params: QueryStateDict, result: Result) -> None: raise ValueError("No auxiliary information in percentage token") -def QArCurrencyOrNum(node: Node, params: QueryStateDict, result: Result) -> None: +def QArCurrencyOrNum(node: Node, params: ParamList, result: Result) -> None: amount: Optional[Node] = node.first_child(lambda n: n.has_t_base("amount")) if amount is not None: # Found an amount terminal node @@ -422,15 +420,15 @@ def QArCurrencyOrNum(node: Node, params: QueryStateDict, result: Result) -> None add_num(result.amount, result) -def QArPiQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QArPiQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = "PI" -def QArWithVAT(node: Node, params: QueryStateDict, result: Result) -> None: +def QArWithVAT(node: Node, params: ParamList, result: Result) -> None: result.op = "with_vat" -def QArWithoutVAT(node: Node, params: QueryStateDict, result: Result) -> None: +def QArWithoutVAT(node: Node, params: ParamList, result: Result) -> None: result.op = "without_vat" @@ -438,7 +436,7 @@ def QArVAT(node: Node, params: QueryStateDict, result: Result) -> None: result.qtype = "VSK" -def QArithmetic(node: Node, params: QueryStateDict, result: Result) -> None: +def QArithmetic(node: Node, params: ParamList, result: Result) -> None: # Set query type result.qtype = _ARITHMETIC_QTYPE diff --git a/queries/builtin.py b/queries/builtin.py index c1036820..ee20cf27 100755 --- a/queries/builtin.py +++ b/queries/builtin.py @@ -48,7 +48,7 @@ from search import Search from speech.trans import gssml from queries import AnswerTuple, Query, ResponseDict, ResponseType, QueryStateDict -from tree import Result, Node +from tree import Result, Node, ParamList from utility import cap_first, icequote from queries.util import read_grammar_file @@ -901,7 +901,7 @@ def sentence(state: QueryStateDict, result: Result) -> None: # and are called during tree processing (depth-first, i.e. bottom-up navigation) -def QPerson(node: Node, params: QueryStateDict, result: Result) -> None: +def QPerson(node: Node, params: ParamList, result: Result) -> None: """Person query""" result.qtype = "Person" if "mannsnafn" in result: @@ -914,87 +914,87 @@ def QPerson(node: Node, params: QueryStateDict, result: Result) -> None: assert False -def QPersonPronoun(node: Node, params: QueryStateDict, result: Result) -> None: +def QPersonPronoun(node: Node, params: ParamList, result: Result) -> None: """Persónufornafn: hann, hún, það""" result.persónufornafn = result._nominative -def QCompany(node: Node, params: QueryStateDict, result: Result) -> None: +def QCompany(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Company" result.qkey = result.fyrirtæki -def QEntity(node: Node, params: QueryStateDict, result: Result) -> None: +def QEntity(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Entity" assert "qkey" in result -def QTitle(node: Node, params: QueryStateDict, result: Result) -> None: +def QTitle(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Title" result.qkey = result.titill -def QWord(node: Node, params: QueryStateDict, result: Result) -> None: +def QWord(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Word" assert "qkey" in result -def QSearch(node: Node, params: QueryStateDict, result: Result) -> None: +def QSearch(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Search" # Return the entire query text as the search key result.qkey = result._text -def QRepeat(node: Node, params: QueryStateDict, result: Result) -> None: +def QRepeat(node: Node, params: ParamList, result: Result) -> None: """Request to repeat the last query answer""" result.qkey = "" result.qtype = "Repeat" -def Sérnafn(node: Node, params: QueryStateDict, result: Result) -> None: +def Sérnafn(node: Node, params: ParamList, result: Result) -> None: """Sérnafn, stutt eða langt""" result.sérnafn = result._nominative -def Fyrirtæki(node: Node, params: QueryStateDict, result: Result) -> None: +def Fyrirtæki(node: Node, params: ParamList, result: Result) -> None: """Fyrirtækisnafn, þ.e. sérnafn + ehf./hf./Inc. o.s.frv.""" result.fyrirtæki = result._nominative -def Mannsnafn(node: Node, params: QueryStateDict, result: Result) -> None: +def Mannsnafn(node: Node, params: ParamList, result: Result) -> None: """Hreint mannsnafn, þ.e. án ávarps og titils""" result.mannsnafn = result._nominative -def EfLiður(node: Node, params: QueryStateDict, result: Result) -> None: +def EfLiður(node: Node, params: ParamList, result: Result) -> None: """Eignarfallsliðir haldast óbreyttir, þ.e. þeim á ekki að breyta í nefnifall""" result._nominative = result._text -def FsMeðFallstjórn(node: Node, params: QueryStateDict, result: Result) -> None: +def FsMeðFallstjórn(node: Node, params: ParamList, result: Result) -> None: """Forsetningarliðir haldast óbreyttir, þ.e. þeim á ekki að breyta í nefnifall""" result._nominative = result._text -def QEntityKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QEntityKey(node: Node, params: ParamList, result: Result) -> None: if "sérnafn" in result: result.qkey = result.sérnafn else: result.qkey = result._nominative -def QTitleKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QTitleKey(node: Node, params: ParamList, result: Result) -> None: """Titill""" result.titill = result._nominative -def QWordNounKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QWordNounKey(node: Node, params: ParamList, result: Result) -> None: result.qkey = result._canonical -def QWordPersonKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QWordPersonKey(node: Node, params: ParamList, result: Result) -> None: if "mannsnafn" in result: result.qkey = result.mannsnafn elif "sérnafn" in result: @@ -1003,9 +1003,9 @@ def QWordPersonKey(node: Node, params: QueryStateDict, result: Result) -> None: result.qkey = result._nominative -def QWordEntityKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QWordEntityKey(node: Node, params: ParamList, result: Result) -> None: result.qkey = result._nominative -def QWordVerbKey(node: Node, params: QueryStateDict, result: Result) -> None: +def QWordVerbKey(node: Node, params: ParamList, result: Result) -> None: result.qkey = result._root diff --git a/queries/bus.py b/queries/bus.py index 141783c4..431f25f2 100755 --- a/queries/bus.py +++ b/queries/bus.py @@ -706,7 +706,11 @@ def assemble(x: Iterable[str]) -> str: voice_answer = assemble(va) return dict(answer=answer), answer, voice_answer -_ROUTE_SORT: Callable[[str], int] = lambda num: int("".join(i for i in num if i.isdecimal())) + +_ROUTE_SORT: Callable[[str], int] = lambda num: int( + "".join(i for i in num if i.isdecimal()) +) + def query_which_route(query: Query, result: Result): """Which routes stop at a given bus stop""" diff --git a/queries/counting.py b/queries/counting.py index a6bdc667..8ee4d0d6 100755 --- a/queries/counting.py +++ b/queries/counting.py @@ -32,7 +32,7 @@ from speech.trans import gssml from queries.util import parse_num, gen_answer, read_grammar_file from queries import Query, QueryStateDict -from tree import Result, Node +from tree import Result, Node, ParamList _COUNTING_QTYPE = "Counting" @@ -59,28 +59,28 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("counting") -def QCountingQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _COUNTING_QTYPE -def QCountingUp(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingUp(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CountUp" -def QCountingDown(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingDown(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CountDown" -def QCountingBetween(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingBetween(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CountBetween" -def QCountingFirstNumber(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingFirstNumber(node: Node, params: ParamList, result: Result) -> None: result.first_num = int(parse_num(node, result._canonical)) -def QCountingSecondNumber(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingSecondNumber(node: Node, params: ParamList, result: Result) -> None: result.second_num = int(parse_num(node, result._canonical)) @@ -89,7 +89,7 @@ def QCountingSecondNumber(node: Node, params: QueryStateDict, result: Result) -> _MAX_COUNT = 100 -def QCountingSpeed(node: Node, params: QueryStateDict, result: Result) -> None: +def QCountingSpeed(node: Node, params: ParamList, result: Result) -> None: result.delay = _SPEED2DELAY.get(node.contained_text()) diff --git a/queries/currency.py b/queries/currency.py index 615ec9d0..60d98273 100755 --- a/queries/currency.py +++ b/queries/currency.py @@ -41,7 +41,7 @@ is_plural, read_grammar_file, ) -from tree import Result, Node, NonterminalNode +from tree import Result, Node, NonterminalNode, ParamList from speech.trans import gssml # Lemmas of keywords that could indicate that the user is trying to use this module @@ -118,7 +118,7 @@ def add_currency(curr: str, result: Result) -> None: rn.append(curr) -def QCurrency(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurrency(node: Node, params: ParamList, result: Result) -> None: """Currency query""" result.qtype = "Currency" result.qkey = result._canonical @@ -131,7 +131,7 @@ def QCurNumberWord(node: Node, params: QueryStateDict, result: Result) -> None: result["numbers"].append(result._canonical) -def QCurUnit(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurUnit(node: Node, params: ParamList, result: Result) -> None: """Obtain the ISO currency code from the last three letters in the child nonterminal name.""" child = cast(NonterminalNode, node.child) @@ -139,28 +139,28 @@ def QCurUnit(node: Node, params: QueryStateDict, result: Result) -> None: add_currency(currency, result) -def QCurExchangeRate(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurExchangeRate(node: Node, params: ParamList, result: Result) -> None: result.op = "exchange" result.desc = result._text -def QCurGeneralRate(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurGeneralRate(node: Node, params: ParamList, result: Result) -> None: result.op = "general" result.desc = result._text -def QCurGeneralCost(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurGeneralCost(node: Node, params: ParamList, result: Result) -> None: result.op = "general" result.desc = result._text -def QCurCurrencyIndex(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurCurrencyIndex(node: Node, params: ParamList, result: Result) -> None: result.op = "index" result.desc = result._text add_currency("GVT", result) -def QCurConvertAmount(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurConvertAmount(node: Node, params: ParamList, result: Result) -> None: # Hvað eru [X] margir [Y] - this is the X part amount: Optional[Node] = node.first_child(lambda n: n.has_t_base("amount")) if amount is not None: @@ -180,12 +180,12 @@ def QCurConvertAmount(node: Node, params: QueryStateDict, result: Result) -> Non result.desc = result._text -def QCurConvertTo(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurConvertTo(node: Node, params: ParamList, result: Result) -> None: # Hvað eru [X] margir [Y] - this is the Y part result.currency = result._nominative -def QCurMuch(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurMuch(node: Node, params: ParamList, result: Result) -> None: # 'Hvað eru þrír dollarar mikið [í evrum]?' # We assume that this means conversion to ISK if no currency is specified if "currency" not in result: @@ -193,7 +193,7 @@ def QCurMuch(node: Node, params: QueryStateDict, result: Result) -> None: add_currency("ISK", result) -def QCurAmountConversion(node: Node, params: QueryStateDict, result: Result) -> None: +def QCurAmountConversion(node: Node, params: ParamList, result: Result) -> None: result.op = "convert" diff --git a/queries/date.py b/queries/date.py index e821859e..39d68786 100755 --- a/queries/date.py +++ b/queries/date.py @@ -71,7 +71,7 @@ sing_or_plur, read_grammar_file, ) -from tree import Result, Node, TerminalNode +from tree import Result, Node, TerminalNode, ParamList from settings import changedlocale from speech.trans import gssml @@ -143,43 +143,43 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("date") -def QDateQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = _DATE_QTYPE -def QDateCurrent(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateCurrent(node: Node, params: ParamList, result: Result) -> None: result["now"] = True -def QDateNextDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateNextDay(node: Node, params: ParamList, result: Result) -> None: result["tomorrow"] = True -def QDatePrevDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDatePrevDay(node: Node, params: ParamList, result: Result) -> None: result["yesterday"] = True -def QDateHowLongUntil(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateHowLongUntil(node: Node, params: ParamList, result: Result) -> None: result["until"] = True -def QDateHowLongSince(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateHowLongSince(node: Node, params: ParamList, result: Result) -> None: result["since"] = True -def QDateWhenIs(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWhenIs(node: Node, params: ParamList, result: Result) -> None: result["when"] = True -def QDateWhichYear(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWhichYear(node: Node, params: ParamList, result: Result) -> None: result["year"] = True -def QDateLeapYear(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateLeapYear(node: Node, params: ParamList, result: Result) -> None: result["leap"] = True -def Árið(node: Node, params: QueryStateDict, result: Result) -> None: +def Árið(node: Node, params: ParamList, result: Result) -> None: y_node = node.first_child(lambda n: True) assert isinstance(y_node, TerminalNode) y = y_node.contained_year @@ -188,7 +188,7 @@ def Árið(node: Node, params: QueryStateDict, result: Result) -> None: result["target"] = datetime(day=1, month=1, year=y) -def QDateAbsOrRel(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateAbsOrRel(node: Node, params: ParamList, result: Result) -> None: datenode = node.first_child(lambda n: True) assert isinstance(datenode, TerminalNode) cdate = datenode.contained_date @@ -217,106 +217,106 @@ def QDateAbsOrRel(node: Node, params: QueryStateDict, result: Result) -> None: raise ValueError(f"No date in {str(datenode)}") -def QDateWhitsun(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWhitsun(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "hvítasunnudagur" result["target"] = next_easter() + timedelta(days=49) -def QDateAscensionDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateAscensionDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "uppstigningardagur" result["target"] = next_easter() + timedelta(days=39) -def QDateAshDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateAshDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "öskudagur" result["target"] = next_easter() - timedelta(days=46) -def QDateBunDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateBunDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "bolludagur" result["target"] = next_easter() - timedelta(days=48) # 7 weeks before easter -def QDateHalloween(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateHalloween(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "hrekkjavaka" result["target"] = dnext(datetime(year=datetime.today().year, month=10, day=31)) -def QDateSovereigntyDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateSovereigntyDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "fullveldisdagurinn" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=1)) -def QDateFirstDayOfSummer(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateFirstDayOfSummer(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "sumardagurinn fyrsti" # !!! BUG: This is not correct in all cases d = dnext(datetime(year=datetime.today().year, month=4, day=18)) result["target"] = next_weekday(d, 3) -def QDateThorlaksMass(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateThorlaksMass(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "þorláksmessa" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=23)) -def QDateChristmasEve(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateChristmasEve(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "aðfangadagur jóla" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=24)) -def QDateChristmasDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateChristmasDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "jóladagur" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=25)) -def QDateNewYearsEve(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateNewYearsEve(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "gamlársdagur" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=31)) -def QDateNewYearsDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateNewYearsDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "nýársdagur" result["target"] = dnext(datetime(year=datetime.today().year, month=1, day=1)) -def QDateNewYear(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateNewYear(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "áramótin" result["is_verb"] = "eru" result["target"] = dnext(datetime(year=datetime.today().year, month=1, day=1)) -def QDateWorkersDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWorkersDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "baráttudagur verkalýðsins" result["target"] = dnext(datetime(year=datetime.today().year, month=5, day=1)) -def QDateEaster(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateEaster(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "páskar" result["is_verb"] = "eru" result["target"] = next_easter() -def QDateEasterSunday(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateEasterSunday(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "páskadagur" result["target"] = next_easter() -def QDateGoodFriday(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateGoodFriday(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "föstudagurinn langi" result["target"] = next_easter() + timedelta(days=-2) -def QDateMaundyThursday(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateMaundyThursday(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "skírdagur" result["target"] = next_easter() + timedelta(days=-3) -def QDateNationalDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateNationalDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "þjóðhátíðardagurinn" result["target"] = dnext(datetime(year=datetime.today().year, month=6, day=17)) -def QDateBankHoliday(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateBankHoliday(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "frídagur verslunarmanna" # First Monday of August result["target"] = this_or_next_weekday( @@ -324,7 +324,7 @@ def QDateBankHoliday(node: Node, params: QueryStateDict, result: Result) -> None ) -def QDateCultureNight(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateCultureNight(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "menningarnótt" # Culture night is on the first Saturday after Reykjavík's birthday on Aug 18th aug18 = dnext(datetime(year=datetime.today().year, month=8, day=18)) @@ -332,73 +332,73 @@ def QDateCultureNight(node: Node, params: QueryStateDict, result: Result) -> Non result["target"] = next_weekday(aug18, 5) # Find the next Saturday -def QDateValentinesDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateValentinesDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "valentínusardagur" result["target"] = dnext(datetime(year=datetime.today().year, month=2, day=14)) -def QDateMansDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateMansDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "bóndadagur" jan19 = dnext(datetime(year=datetime.today().year, month=1, day=19)) result["target"] = next_weekday(jan19, 4) # First Friday after Jan 19 -def QDateWomansDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWomansDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "konudagur" feb18 = dnext(datetime(year=datetime.today().year, month=2, day=18)) result["target"] = next_weekday(feb18, 6) # First Sunday after Feb 18 -def QDateMardiGrasDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateMardiGrasDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "sprengidagur" result["target"] = next_easter() - timedelta(days=47) -def QDatePalmSunday(node: Node, params: QueryStateDict, result: Result) -> None: +def QDatePalmSunday(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "pálmasunnudagur" result["target"] = next_easter() - timedelta(days=7) # Week before Easter Sunday -def QDateSeamensDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateSeamensDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "sjómannadagur" june1 = dnext(datetime(year=datetime.today().year, month=6, day=1)) result["target"] = this_or_next_weekday(june1, 6) # First Sunday in June -def QDateMothersDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateMothersDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "mæðradagur" may8 = dnext(datetime(year=datetime.today().year, month=5, day=8)) result["target"] = this_or_next_weekday(may8, 6) # Second Sunday in May -def QDateFathersDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateFathersDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "feðradagur" nov8 = dnext(datetime(year=datetime.today().year, month=11, day=8)) result["target"] = this_or_next_weekday(nov8, 6) # Second Sunday in November -def QDateIcelandicTongueDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateIcelandicTongueDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "dagur íslenskrar tungu" result["target"] = dnext(datetime(year=datetime.today().year, month=11, day=16)) -def QDateSecondChristmasDay(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateSecondChristmasDay(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "annar í jólum" result["target"] = dnext(datetime(year=datetime.today().year, month=12, day=26)) -def QDateFirstDayOfWinter(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateFirstDayOfWinter(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "fyrsti vetrardagur" result["target"] = None # To be completed -def QDateSummerSolstice(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateSummerSolstice(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "sumarsólstöður" result["is_verb"] = "eru" result["target"] = None # To be completed -def QDateWinterSolstice(node: Node, params: QueryStateDict, result: Result) -> None: +def QDateWinterSolstice(node: Node, params: ParamList, result: Result) -> None: result["desc"] = "vetrarsólstöður" result["is_verb"] = "eru" result["target"] = None # To be completed diff --git a/queries/dialogues/.keep b/queries/dialogues/.keep new file mode 100644 index 00000000..e69de29b diff --git a/queries/dialogues/fruitseller.toml b/queries/dialogues/fruitseller.toml new file mode 100644 index 00000000..108105fb --- /dev/null +++ b/queries/dialogues/fruitseller.toml @@ -0,0 +1,54 @@ +[extras] +expiration_time = 900 # 15 minutes + +[[resources]] +name = "Fruits" +type = "ListResource" +needs_confirmation = true +# TODO: Keep singular and plural forms of fruits (or options more generally) for formatting answer? +prompts.initial = "Hvaða ávexti má bjóða þér?" +prompts.options = "Ávextirnir sem eru í boði eru appelsínur, bananar, epli og perur." +prompts.empty = "Karfan er núna tóm. Hvaða ávexti má bjóða þér?" +prompts.confirm = "Viltu staðfesta ávextina {list_items}?" +prompts.repeat = "Pöntunin samanstendur af {list_items}. Verður það eitthvað fleira?" + +[[resources]] +name = "Date" +type = "DateResource" +requires = ["Fruits"] + +[[resources]] +name = "Time" +type = "TimeResource" +requires = ["Fruits"] + +[[resources]] +name = "DateTime" +type = "WrapperResource" +requires = ["Date", "Time"] +needs_confirmation = true +prompts.initial = "Hvenær viltu fá ávextina?" +prompts.time_fulfilled = "Afhendingin verður klukkan {time}. Hvaða dagsetningu viltu fá ávextina?" +prompts.date_fulfilled = "Afhendingin verður {date}. Klukkan hvað viltu fá ávextina?" +prompts.confirm = "Afhending pöntunar er {date_time}. Viltu staðfesta afhendinguna?" + +# [[resources]] +# name = "Date" +# type = "DatetimeResource" +# requires = ["Fruits"] + +# [[resources]] +# name = "ConfirmOrder" +# type = "YesNoResource" +# prompt = "Viltu staðfesta þessa pöntun?" +# requires = ["Fruits", "Date"] + +[[resources]] +name = "Final" +type = "FinalResource" +requires = ["DateTime"] +prompts.final = "Pöntunin þín er {fruits} og verður afhent {date_time}." +prompts.cancelled = "Móttekið, hætti við pöntunina." +prompts.timed_out = "Ávaxtapöntunin þín rann út á tíma. Vinsamlegast byrjaðu aftur." + +# TODO: Add needs_confirmation where appropriate diff --git a/queries/dialogues/pizza.toml b/queries/dialogues/pizza.toml new file mode 100644 index 00000000..47da9aa8 --- /dev/null +++ b/queries/dialogues/pizza.toml @@ -0,0 +1,90 @@ +[[resources]] +name = "PizzaOrder" +type = "WrapperResource" +#requires = ["Pizzas"] #, "Sides", "Sauces", "Drinks"] +needs_confirmation = true +prompts.initial = "Hvað má bjóða þér?" +prompts.added_pizzas = "{pizzas} var bætt við pöntunina. Pöntunin inniheldur {total_pizzas}. Var það eitthvað fleira?" +prompts.confirmed_pizzas = "Var það eitthvað fleira?" + +# [[resources]] +# name = "PickupDelivery" +# type = "OrResource" + +[[resources]] +name = "Final" +type = "FinalResource" +requires = ["PizzaOrder"] +prompts.final = "Pítsupöntunin þín er móttekin." +prompts.cancelled = "Móttekið, hætti við pítsu pöntunina." +prompts.timed_out = "Pítsupöntunin þín rann út á tíma. Vinsamlegast byrjaðu aftur." + +# Dynamic resources that get created when a user orders a pizza +[[dynamic_resources]] +name = "Pizza" +type = "WrapperResource" +requires = ["Type", "Size", "Crust"] +prompts.initial = "Hvernig á pítsa {number} að vera?" +prompts.initial_single = "Hvernig pítsu viltu?" +prompts.type = "Hvað viltu hafa á pítsu {number}?" +prompts.type_single = "Hvað viltu hafa á pítsunni" +prompts.size = "Hversu stór á pítsa {number} að vera?" +prompts.size_single = "Hversu stór á pítsan að vera?" +prompts.crust = "Hvernig botn viltu á pítsu {number}?" +prompts.crust_single = "Hvernig botn viltu á pítsuna?" + +[[dynamic_resources]] +name = "Type" +type = "OrResource" +requires = ["Toppings", "FromMenu"] #, "Split"] +prefer_over_wrapper = false + +[[dynamic_resources]] +name = "Toppings" +type = "DictResource" + +[[dynamic_resources]] +name = "FromMenu" +type = "StringResource" + +[[dynamic_resources]] +name = "Size" +type = "StringResource" +prompts.initial = "Hvaða stærð af pítsu viltu fá?" + +[[dynamic_resource]] +name = "Split" +type = "WrapperResource" +requires = ["Side1", "Side2"] + +[[dynamic_resource]] +name = "Side1" +type = "OrResource" +requires = ["Toppings", "FromMenu"] + +[[dynamic_resource]] +name = "Side2" +type = "OrResource" +requires = ["Toppings", "FromMenu"] + +[[dynamic_resources]] +name = "Crust" +type = "StringResource" + +# [[dynamic_resources]] +# name = "Sides" +# type = "ListResource" + +# [[dynamic_resources]] +# name = "Dips" +# type = "ListResource" + +# [[dynamic_resources]] +# name = "Drinks" +# type = "ListResource" + +# [[dynamic_resources]] +# name = "Pickup" + +# [[dynamic_resources]] +# name = "Delivery" diff --git a/queries/dialogues/theater.toml b/queries/dialogues/theater.toml new file mode 100644 index 00000000..062d58db --- /dev/null +++ b/queries/dialogues/theater.toml @@ -0,0 +1,83 @@ +[[resources]] +name = "Show" +type = "ListResource" +cascade_state = true +needs_confirmation = true +prompts.initial = "Hvaða sýningu má bjóða þér að fara á?" +prompts.options = "Sýningarnar sem eru í boði eru: {options}" +prompts.confirm = "Þú valdir sýninguna {show}, viltu halda áfram með pöntunina?" +prompts.no_show_matched = "Því miður er þessi sýning ekki í boði. Vinsamlegast reyndu aftur." +prompts.no_show_matched_data_exists = "Því miður er þessi sýning ekki í boði. Síðasta valda sýning var {show}, viltu halda áfram með hana?" + +[[resources]] +name = "ShowDate" +type = "DateResource" +needs_confirmation = true +requires = ["Show"] + +[[resources]] +name = "ShowTime" +type = "TimeResource" +needs_confirmation = true +requires = ["Show"] + +[[resources]] +name = "ShowDateTime" +type = "WrapperResource" +requires = ["ShowDate", "ShowTime"] +cascade_state = true +needs_confirmation = true +prompts.initial = "Hvenær viltu fara á sýninguna {show}? Í boði eru {date_number} dagsetningar.\n{dates}" +prompts.options = "Í boði eru {date_number} dagsetningar. {options}" +prompts.confirm = "Þú valdir {date}, viltu halda áfram og velja fjölda sæta?" +prompts.many_matching_times = "Margar dagsetningar pössuðu við gefna tímasetningu, vinsamlegast reyndu aftur." +prompts.multiple_times_for_date = "Fyrir dagsetninguna sem þú valdir eru nokkrar tímasetningar, hverja af þeim viltu bóka?\nValmöguleikarnir eru:{times}" +prompts.no_date_matched = "Engin sýning er í boði fyrir gefna dagsetningu, vinsamlegast reyndu aftur." +prompts.no_time_matched = "Engin sýning er í boði fyrir gefna tímasetningu, vinsamlegast reyndu aftur." +prompts.no_date_available = "{show} hefur engar dagsetningar í boði. Vinsamlegast veldu aðra sýningu." +prompts.no_date_chosen = "Vinsamlegast veldu dagsetningu til að fá mögulegar tímasetningar." + +[[resources]] +name = "ShowSeatCount" +type = "NumberResource" +requires = ["ShowDateTime"] +cascade_state = true +needs_confirmation = true +prompts.initial = "Hversu mörg sæti viltu bóka?" +prompts.confirm = "Þú valdir {seats} sæti, viltu halda áfram og velja staðsetningu sætanna?" +prompts.invalid_seat_count = "Fjöldi sæta þarfa að vera hærri en einn. Vinsamlegast reyndu aftur." + +[[resources]] +name = "ShowSeatRow" +type = "ListResource" +requires = ["ShowSeatCount"] +cascade_state = true +needs_confirmation = true +prompts.initial = "Að minnsta kosti {seats} sæti eru í boði í röðum {seat_rows}. Í hvaða röð viltu sitja?" +prompts.options = "Raðir {rows} eru með {seats} laus sæti." +prompts.confirm = "Þú valdir röð {row}, viltu halda áfram?" +prompts.no_row_matched = "Því miður er þessi röð ekki með {seats} laus sæti. Vinsamlegast reyndu aftur." +prompts.not_enough_seats = "Því miður er engin röð með {seats} laus sæti á þessari sýningu, vinsamlegast prófaðu aðra dagsetningu." + +[[resources]] +name = "ShowSeatNumber" +type = "ListResource" +requires = ["ShowSeatRow"] +cascade_state = true +needs_confirmation = true +prompts.initial = "Sæti {seats} eru í boði í röð {row}, hvaða sæti má bjóða þér?" +prompts.options = "Sætin sem eru í boði í röð {row} eru {options}" +prompts.confirm = "Þú valdir sæti {seats}, viltu halda áfram?" +prompts.wrong_number_seats_selected = "Þú valdir {chosen_seats} sæti, en þú baðst um {seats}. Vinsamlegast reyndu aftur." +prompts.seats_unavailable = "Valin sæti eru ekki laus, vinsamlegast reyndu aftur." + +[[resources]] +name = "Final" +type = "FinalResource" +requires = ["ShowSeatNumber"] +prompts.final = "Þú bókaðir sæti {seats} í röð {row} fyrir sýninguna {show} {date_time}." +prompts.cancelled = "Móttekið, hætti við leikhús pöntunina." +prompts.timed_out = "Leikhúsmiðapöntunin þín rann út á tíma. Vinsamlegast byrjaðu aftur." + +# TODO: Add needs_confirmation where appropriate +# TODO: Add a resource for the payment method diff --git a/queries/dictionary.py b/queries/dictionary.py index 0f46b618..4d0e13c2 100755 --- a/queries/dictionary.py +++ b/queries/dictionary.py @@ -28,7 +28,7 @@ import logging from queries import Query, QueryStateDict -from tree import Result, Node +from tree import Result, Node, ParamList from utility import cap_first, icequote from queries.util import ( @@ -48,11 +48,11 @@ GRAMMAR = read_grammar_file("dictionary") -def QDictSubjectNom(node: Node, params: QueryStateDict, result: Result) -> None: +def QDictSubjectNom(node: Node, params: ParamList, result: Result) -> None: result.qkey = result._text -def QDictWordQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QDictWordQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Dictionary" diff --git a/queries/disabled/.keep b/queries/disabled/.keep new file mode 100644 index 00000000..e69de29b diff --git a/queries/disabled/pizza.py b/queries/disabled/pizza.py new file mode 100644 index 00000000..81976999 --- /dev/null +++ b/queries/disabled/pizza.py @@ -0,0 +1,498 @@ +""" + + Greynir: Natural language processing for Icelandic + + Randomness query response module + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + This query module handles dialogue related to ordering pizza. +""" +from typing import Any, Dict, List, Optional, cast +import logging +import random + +from query import Query, QueryStateDict +from tree import ParamList, Result, Node +from queries import ( + AnswerTuple, + gen_answer, + parse_num, + read_grammar_file, + sing_or_plur, +) +from queries.util.num import number_to_text, numbers_to_text +from queries.extras.resources import ( + FinalResource, + DictResource, + OrResource, + ResourceState, + StringResource, + WrapperResource, +) +from queries.extras.dialogue import ( + AnsweringFunctionMap, + DialogueStateManager, +) + +_PIZZA_QTYPE = "pizza" +_START_DIALOGUE_QTYPE = "pizza_start" + +TOPIC_LEMMAS = ["pizza", "pitsa"] + + +def help_text(lemma: str) -> str: + """Help text to return when query.py is unable to parse a query but + one of the above lemmas is found in it""" + return "Ég skil þig ef þú segir til dæmis: {0}.".format( + random.choice(("Ég vil panta pizzu",)) + ) + + +HANDLE_TREE = True + +# This module involves dialogue functionality +DIALOGUE_NAME = "pizza" + +# The grammar nonterminals this module wants to handle +QUERY_NONTERMINALS = {"QPizza"} + +# The context-free grammar for the queries recognized by this plug-in module +GRAMMAR = read_grammar_file("pizza") + + +def banned_nonterminals(q: Query) -> None: + """ + Returns a set of nonterminals that are not + allowed due to the state of the dialogue + """ + # TODO: Implement this + if not q.in_dialogue(DIALOGUE_NAME): + q.ban_nonterminal("QPizzaQuery") + + +def _generate_order_answer( + resource: WrapperResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + ans: str = "" + + if dsm.extras.get("added_pizzas", False): + total: int = dsm.extras["confirmed_pizzas"] + number: int = dsm.extras["added_pizzas"] + # r1 = dsm.get_resource("Pizza_1") + # r2 = dsm.get_resource("Pizza_2") + # print("Is r1 the same as r2: ", id(r1.state) is id(r2.state)) + print("Added pizzas", number) + ans = resource.prompts["added_pizzas"].format( + pizzas=numbers_to_text( + sing_or_plur(number, "pítsu", "pítsum"), gender="kvk", case="þgf" + ).capitalize(), + total_pizzas=numbers_to_text( + sing_or_plur(total, "fullkláraða pítsu", "fullkláraðar pítsur"), + gender="kvk", + case="þf", + ), + ) + dsm.extras["added_pizzas"] = 0 + return (dict(answer=ans), ans, ans) + if dsm.extras.get("confirmed_pizzas", False): + total: int = dsm.extras["confirmed_pizzas"] + print("Total pizzas: ", total) + ans = resource.prompts["confirmed_pizzas"] + return (dict(answer=ans), ans, ans) + return gen_answer(resource.prompts["initial"]) + + +def _generate_pizza_answer( + resource: WrapperResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + order_resource = dsm.get_resource("PizzaOrder") + for child in dsm._resource_graph[order_resource]["children"]: + print("!!!$$$$Pizza: ", child.name, child.state) + print("Generating pizza answer") + print("Generate pizza resource name: ", resource.name) + type_resource: OrResource = cast(OrResource, dsm.get_children(resource)[0]) + print("Type state: {}".format(type_resource.data)) + size_resource: StringResource = cast(StringResource, dsm.get_children(resource)[1]) + print("Size state: {}".format(size_resource.data)) + crust_resource: StringResource = cast(StringResource, dsm.get_children(resource)[2]) + print("Crust state: {}".format(crust_resource.data)) + index: str = resource.name.split("_")[-1] + number: int = int(index) + # Pizza text formatting + pizza_text: str = f"\n" + if any( + confirmed + for confirmed in [ + type_resource.is_confirmed, + size_resource.is_confirmed, + crust_resource.is_confirmed, + ] + ): + pizza_text += f"Pítsa {number}:\n" + if size_resource.is_confirmed: + pizza_text += f" - {size_resource.data.capitalize()}\n" + if crust_resource.is_confirmed: + pizza_text += f" - {crust_resource.data.capitalize()} botn\n" + if type_resource.is_confirmed: + toppings_resource: DictResource = cast( + DictResource, dsm.get_children(type_resource)[0] + ) + if toppings_resource.is_confirmed: + pizza_text += f" - Álegg: \n" + for topping in toppings_resource.data: + pizza_text += f" - {topping.capitalize()}\n" + else: + menu_resource: StringResource = cast( + StringResource, dsm.get_children(type_resource)[1] + ) + pizza_text += f" - Tegund: {menu_resource.data.capitalize()}\n" + if resource.is_unfulfilled: + print("Unfulfilled pizza") + if number == 1: + ans = resource.prompts["initial_single"] + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + ans = resource.prompts["initial"].format(number=number_to_text(number)) + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + if resource.is_partially_fulfilled: + print("Partially fulfilled pizza") + if type_resource.is_unfulfilled: + if number == 1: + ans = resource.prompts["type_single"] + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + ans = resource.prompts["type"].format(number=number_to_text(number)) + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + if type_resource.is_confirmed and size_resource.is_unfulfilled: + print("Confirmed type, unfulfilled size") + if number == 1: + ans = resource.prompts["size_single"] + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + ans = resource.prompts["size"].format(number=number_to_text(number)) + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + if ( + type_resource.is_confirmed + and size_resource.is_confirmed + and crust_resource.is_unfulfilled + ): + if number == 1: + ans = resource.prompts["crust_single"] + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + ans = resource.prompts["crust"].format(number=number_to_text(number)) + text_ans = ans + pizza_text + return (dict(answer=text_ans), text_ans, ans) + + +def _generate_final_answer( + resource: FinalResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + if resource.is_cancelled: + return gen_answer(resource.prompts["cancelled"]) + + return gen_answer(resource.prompts["final"]) + + +def QPizzaDialogue(node: Node, params: ParamList, result: Result) -> None: + if "qtype" not in result: + result.qtype = _PIZZA_QTYPE + + +def QPizzaHotWord(node: Node, params: ParamList, result: Result) -> None: + result.qtype = _START_DIALOGUE_QTYPE + print("ACTIVATING PIZZA MODULE") + Query.get_dsm(result).hotword_activated() + + +def QPizzaNumberAndSpecificationWrapper( + node: Node, params: ParamList, result: Result +) -> None: + """ + Dynamically adds a number of pizzas if there is no + current pizza, otherwise adds ingredients to the current pizza. + The specification of the pizzas is gotten from the result. + """ + print("In number and specification wrapper") + dsm: DialogueStateManager = Query.get_dsm(result) + resource: WrapperResource = cast(WrapperResource, dsm.current_resource) + pizzas: List[Dict[str, Any]] = result.get("pizzas", []) + print("Current resource: ", resource.name) + print("Resource.name == PizzaOrder", resource.name == "PizzaOrder") + print( + "(dsm.extras.pop(adding_pizzas, False): ", + (dsm.extras.get("adding_pizzas", False)), + ) + print("Pizzas: ", pizzas) + for pizza in pizzas: + print("Pizza: ", pizza) + if resource.name == "PizzaOrder": + # Create a new pizza + print("Adding new pizza") + dsm.add_dynamic_resource("Pizza", "PizzaOrder") + # dsm.extras["adding_pizzas"] = True + print("Done adding new pizza", dsm.get_children(resource)[-1]) + dsm.extras["total_pizzas"] = dsm.extras.get("total_pizzas", 0) + 1 + print("Done adding to total pizzas") + pizza_resource: WrapperResource = cast( + WrapperResource, dsm.get_children(resource)[-1] + ) + else: + resource = cast(WrapperResource, dsm.get_resource("PizzaOrder")) + pizza_resource = cast(WrapperResource, dsm.current_resource) + # Add to the pizza + print(">>> Pizza resource: , ", pizza_resource.name) + type_resource: OrResource = cast( + OrResource, dsm.get_children(pizza_resource)[0] + ) + toppings: Optional[Dict[str, int]] = pizza.get("toppings", None) + if toppings: + toppings_resource = cast(DictResource, dsm.get_children(type_resource)[0]) + for (topping, amount) in toppings.items(): + print("Topping in for loop: ", topping) + toppings_resource.data[topping] = amount + dsm.skip_other_resources(type_resource, toppings_resource) + dsm.set_resource_state(toppings_resource.name, ResourceState.CONFIRMED) + dsm.set_resource_state(type_resource.name, ResourceState.CONFIRMED) + + menu: Optional[str] = pizza.get("menu", None) + if menu: + menu_resource: StringResource = cast( + StringResource, dsm.get_children(type_resource)[1] + ) + menu_resource.data = menu + dsm.skip_other_resources(type_resource, menu_resource) + dsm.set_resource_state(menu_resource.name, ResourceState.CONFIRMED) + dsm.set_resource_state(type_resource.name, ResourceState.CONFIRMED) + size: Optional[str] = pizza.get("size", None) + print("Size: ", size) + if size: + size_resource: StringResource = cast( + StringResource, dsm.get_children(pizza_resource)[1] + ) + print("Size resource name: ", size_resource.name) + size_resource.data = size + dsm.set_resource_state(size_resource.name, ResourceState.CONFIRMED) + print("Size state: ", size_resource.state) + + crust: Optional[str] = pizza.get("crust", None) + print("Crust: ", crust) + if crust: + crust_resource: StringResource = cast( + StringResource, dsm.get_children(pizza_resource)[2] + ) + crust_resource.data = crust + dsm.set_resource_state(crust_resource.name, ResourceState.CONFIRMED) + dsm.update_wrapper_state(pizza_resource) + if pizza_resource.state == ResourceState.CONFIRMED: + # dsm.set_resource_state(pizza_resource.name, ResourceState.CONFIRMED) + dsm.extras["confirmed_pizzas"] = dsm.extras.get("confirmed_pizzas", 0) + 1 + dsm.extras["added_pizzas"] = dsm.extras.get("added_pizzas", 0) + 1 + result["new_pizza"] = pizza_resource + + number: int = pizza.get("count", 1) - 1 + print("Getting new pizza") + for _ in range(number): + dsm.duplicate_dynamic_resource(pizza_resource) + print("Duplicating resource: ", pizza_resource.name) + dsm.extras["total_pizzas"] = dsm.extras.get("total_pizzas", 0) + 1 + if pizza_resource.is_confirmed: + dsm.extras["confirmed_pizzas"] = ( + dsm.extras.get("confirmed_pizzas", 0) + 1 + ) + dsm.extras["added_pizzas"] = dsm.extras.get("added_pizzas", 0) + 1 + + +def QPizzaNumberAndSpecification(node: Node, params: ParamList, result: Result) -> None: + """ + Adds pizza information to the result. + """ + print("QPizzaNumberAndSpecification") + toppings: Optional[Dict[str, int]] = result.dict.pop("toppings", None) + print("Toppings: ", toppings) + menu: Optional[str] = result.dict.pop("menu", None) + print("Menu: ", menu) + size: Optional[str] = result.dict.pop("pizza_size", None) + print("Size: ", size) + crust: Optional[str] = result.dict.pop("crust", None) + print("Crust: ", crust) + number: int = result.get("number", 1) + pizza: Dict[str, Any] = { + "count": number, + "toppings": toppings, + "menu": menu, + "size": size, + "crust": crust, + } + print("Pizza in QPizzaNumberAndSpecification: ", pizza) + result.pizzas = [pizza] + + +def QPizzaSpecification(node: Node, params: ParamList, result: Result) -> None: + print("In QPizzaSpecification") + + +def QPizzaToppingsWord(node: Node, params: ParamList, result: Result) -> None: + topping: str = result.dict.pop("real_name", result._nominative) + if "toppings in QPizzaToppingsWord" not in result: + result["toppings"] = {} + result["toppings"][topping] = 1 # TODO: Add support for extra toppings + + +def QPizzaMenuWords(node: Node, params: ParamList, result: Result) -> None: + result.menu = result._root + # TODO: If multiple menu items added at the same time it will be in plural form + + +def QPizzaNum(node: Node, params: ParamList, result: Result) -> None: + number: int = int(parse_num(node, result._nominative)) + if "numbers" not in result: + result["numbers"] = [] + result.numbers.append(number) + result.number = number + + +def QPizzaSizeLarge(node: Node, params: ParamList, result: Result) -> None: + result.pizza_size = "stór" + + +def QPizzaSizeMedium(node: Node, params: ParamList, result: Result) -> None: + result.pizza_size = "miðstærð" + + +def QPizzaMediumWord(node: Node, params: ParamList, result: Result) -> None: + result.pizza_size = "miðstærð" + + +def QPizzaSizeSmall(node: Node, params: ParamList, result: Result) -> None: + result.pizza_size = "lítil" + + +def QPizzaCrustType(node: Node, params: ParamList, result: Result) -> None: + result.crust = result._root + + +def QPizzaPepperoni(node: Node, params: ParamList, result: Result) -> None: + result.real_name = "pepperóní" + + +def QPizzaOlive(node: Node, params: ParamList, result: Result) -> None: + result.real_name = "ólífur" + + +def QPizzaMushroom(node: Node, params: ParamList, result: Result) -> None: + result.real_name = "sveppir" + + +def QPizzaNo(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + resource: WrapperResource = cast(WrapperResource, dsm.current_resource) + print("No resource: ", resource.name) + if resource.name == "PizzaOrder": + dsm.set_resource_state(resource.name, ResourceState.CONFIRMED) + dsm.set_resource_state("Final", ResourceState.CONFIRMED) + + +def QPizzaStatus(node: Node, params: ParamList, result: Result) -> None: + result.qtype = "QPizzaStatus" + dsm: DialogueStateManager = Query.get_dsm(result) + at = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + pizza_string: str = "" + if "confirmed_pizzas" in dsm.extras: + number = dsm.extras["confirmed_pizzas"] + if dsm.extras["confirmed_pizzas"] > 0: + pizza_string = "Pöntunin þín inniheldur {}".format( + numbers_to_text( + sing_or_plur(number, "fullkláraða pítsu", "fullkláraðar pítsur"), + gender="kvk", + case="þf", + ) + ) + print("Pizza status before total") + if "total_pizzas" in dsm.extras: + total = dsm.extras["total_pizzas"] + confirmed = dsm.extras.get("confirmed_pizzas", 0) + if confirmed == 0: + pizza_string = "Pöntunin þín inniheldur" + elif total - confirmed > 0: + pizza_string += " og" + if total - confirmed > 0: + pizza_string += " {}".format( + numbers_to_text( + sing_or_plur( + total - confirmed, "ókláraða pítsu", "ókláraðar pítsur" + ), + gender="kvk", + case="þf", + ) + ) + if total > 0: + pizza_string += ". " + if len(pizza_string) == 0: + pizza_string = "Hingað til eru engar vörur í pöntuninni. " + if at: + (_, ans, voice) = at + ans = pizza_string + ans + voice = pizza_string + voice + dsm.set_answer((dict(answer=ans), ans, voice)) + + +_ANSWERING_FUNCTIONS: AnsweringFunctionMap = { + "PizzaOrder": _generate_order_answer, + "Pizza": _generate_pizza_answer, + "Final": _generate_final_answer, +} + + +def sentence(state: QueryStateDict, result: Result) -> None: + """Called when sentence processing is complete""" + q: Query = state["query"] + dsm: DialogueStateManager = q.dsm + if dsm.not_in_dialogue(): + print("Not in dialogue") + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + try: + print("A") + ans: Optional[AnswerTuple] = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + if "pizza_options" not in result: + q.query_is_command() + print("D", ans) + print("Current resource: ", dsm.current_resource) + if not ans: + print("No answer generated") + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + print("E", result.qtype) + q.set_qtype(result.qtype) + print("F", ans) + q.set_answer(*ans) + print("G") + q.set_beautified_query( + q.beautified_query.replace("Panta", "panta").replace( + "Hver er staðan.", "Hver er staðan?" + ) + ) + except Exception as e: + print("Exception: ", e) + logging.warning("Exception while processing random query: {0}".format(e)) + q.set_error("E_EXCEPTION: {0}".format(e)) + raise + return diff --git a/queries/play.py b/queries/disabled/play.py similarity index 100% rename from queries/play.py rename to queries/disabled/play.py diff --git a/queries/disabled/theater.py b/queries/disabled/theater.py new file mode 100644 index 00000000..50426c66 --- /dev/null +++ b/queries/disabled/theater.py @@ -0,0 +1,1176 @@ +""" + + Greynir: Natural language processing for Icelandic + + Randomness query response module + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + This query module handles dialogue related to theater tickets. +""" + +from typing import Any, Dict, List, Optional, Tuple, cast +from typing_extensions import TypedDict +import json +import logging +import random +import datetime + +from settings import changedlocale +from query import Query, QueryStateDict +from tree import ParamList, Result, Node, TerminalNode +from queries import ( + AnswerTuple, + gen_answer, + natlang_seq, + parse_num, + read_grammar_file, + time_period_desc, +) +from queries.util.num import number_to_text, numbers_to_ordinal, numbers_to_text +from queries.extras.resources import ( + DateResource, + FinalResource, + ListResource, + NumberResource, + Resource, + ResourceState, + TimeResource, + WrapperResource, +) +from queries.extras.dialogue import ( + AnsweringFunctionMap, + DialogueStateManager, +) + +_DIALOGUE_NAME = "theater" +_THEATER_QTYPE = "theater" +_START_DIALOGUE_QTYPE = "theater_start" + +TOPIC_LEMMAS = ["leikhús", "sýning"] + + +def help_text(lemma: str) -> str: + """Help text to return when query.py is unable to parse a query but + one of the above lemmas is found in it""" + return "Ég skil þig ef þú segir til dæmis: {0}.".format( + random.choice(("Hvaða sýningar eru í boði",)) + ) + + +HANDLE_TREE = True + +# This module involves dialogue functionality +DIALOGUE_NAME = "theater" + +# The grammar nonterminals this module wants to handle +QUERY_NONTERMINALS = {"QTheater"} + +# The context-free grammar for the queries recognized by this plug-in module +GRAMMAR = read_grammar_file("theater") + + +def banned_nonterminals(q: Query) -> None: + """ + Returns a set of nonterminals that are not + allowed due to the state of the dialogue + """ + # TODO: Put this back in when the dsm has access to the active dialogue again. + print("afssddsaasdsaffda") + if not q.in_dialogue(DIALOGUE_NAME): + q.ban_nonterminal("QTheaterQuery") + return + print("afss") + resource: str = q.dsm.get_next_active_resource(DIALOGUE_NAME) + print("fdadaf") + if resource == "Show": + q.ban_nonterminal_set( + { + "QTheaterShowDateQuery", + "QTheaterMoreDates", + "QTheaterPreviousDates", + "QTheaterShowSeatCountQuery", + "QTheaterShowLocationQuery", + "QTheaterDateOptions", + "QTheaterRowOptions", + "QTheaterSeatOptions", + } + ) + elif resource == "ShowDateTime": + q.ban_nonterminal_set( + { + "QTheaterShowSeatCountQuery", + "QTheaterShowLocationQuery", + "QTheaterRowOptions", + "QTheaterSeatOptions", + "QTheaterShowOnlyName", + } + ) + elif resource == "ShowSeatCount": + q.ban_nonterminal_set( + { + "QTheaterShowLocationQuery", + "QTheaterRowOptions", + "QTheaterSeatOptions", + "QTheaterRowNum", + "QTheaterShowSeatsNum", + "QTheaterShowOnlyName", + } + ) + elif resource == "ShowSeatRow": + q.ban_nonterminal_set( + { + "QTheaterShowSeats", + "QTheaterSeatCountNum", + "QTheaterShowSeatsNum", + "QTheaterSeatOptions", + "QTheaterShowOnlyName", + } + ) + elif resource == "ShowSeatNumber": + q.ban_nonterminal_set( + { + "QTheaterSeatCountNum", + "QTheaterRowNum", + "QTheaterShowOnlyName", + } + ) + + +class ShowType(TypedDict): + title: str + price: int + show_length: int + date: List[datetime.datetime] + location: List[Tuple[int, int]] + + +_SHOWS: List[ShowType] = [ + { + "title": "Emil í Kattholti", + "price": 5900, + "show_length": 150, + "date": [ + datetime.datetime(2022, 8, 27, 13, 0), + datetime.datetime(2022, 8, 28, 13, 0), + datetime.datetime(2022, 8, 28, 17, 0), + datetime.datetime(2022, 9, 3, 13, 0), + datetime.datetime(2022, 9, 3, 17, 0), + datetime.datetime(2022, 9, 4, 13, 0), + datetime.datetime(2022, 9, 10, 13, 0), + ], + "location": [ + (1, 1), # (row, seat) + (1, 2), + (1, 3), + (1, 4), + (2, 7), + (2, 8), + (2, 9), + (6, 20), + (6, 21), + (6, 22), + (6, 23), + (6, 24), + ], + }, + { + "title": "Lína Langsokkur", + "price": 3900, + "show_length": 90, + "date": [ + datetime.datetime(2022, 8, 27, 13, 0), + datetime.datetime(2022, 8, 28, 13, 0), + datetime.datetime(2022, 8, 28, 17, 0), + datetime.datetime(2022, 9, 3, 13, 0), + datetime.datetime(2022, 9, 3, 17, 0), + datetime.datetime(2022, 9, 4, 13, 0), + datetime.datetime(2022, 9, 10, 13, 0), + ], + "location": [ + (1, 11), # (row, seat) + (1, 12), + (1, 13), + (1, 14), + (2, 7), + (2, 18), + (2, 19), + (6, 20), + (6, 21), + (6, 22), + (6, 23), + (6, 24), + ], + }, +] + +_BREAK_LENGTH = 0.3 # Seconds +_BREAK_SSML = ''.format(_BREAK_LENGTH) + + +def _generate_show_answer( + resource: ListResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + if resource.is_unfulfilled: + return gen_answer(resource.prompts["initial"]) + if resource.is_fulfilled: + return gen_answer(resource.prompts["confirm"].format(show=resource.data[0])) + return None + + +def _generate_date_answer( + resource: ListResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + title = dsm.get_resource("Show").data[0] + dates: list[str] = [] + text_dates: list[str] = [] + index: int = 0 + extras: Dict[str, Any] = dsm.extras + if "page_index" in extras: + index = extras["page_index"] + for show in _SHOWS: + if show["title"] == title: + for date in show["date"]: + with changedlocale(category="LC_TIME"): + text_dates.append(date.strftime("\n - %a %d. %b kl. %H:%M")) + dates.append(date.strftime("\n%A %d. %B klukkan %H:%M")) + date_number: int = 3 if len(dates) >= 3 else len(dates) # nr dates to be shown + start_string: str = ( + "Eftirfarandi dagsetning er í boði:" + if date_number == 1 + else "Næstu tvær dagsetningarnar eru:" + if date_number == 2 + else "Næstu þrjár dagsetningarnar eru:" + ) + if index == 0: + start_string = start_string.replace("Næstu", "Fyrstu", 1) + # Less than 3 dates to be shown + if len(dates) < 3: + index = 0 + extras["page_index"] = 0 + # Last dates to be shown + if index > len(dates) - 3 and len(dates) > 3: + start_string = "Síðustu þrjár dagsetningarnar eru:\n" + index = max(0, len(dates) - 3) + extras["page_index"] = index + # Date is there, not time. Answer with available times + if resource.is_partially_fulfilled: + show_date: Optional[datetime.date] = cast( + DateResource, dsm.get_resource("ShowDate") + ).data + show_times: list[str] = [] + if show_date is not None: + for show in _SHOWS: + if show["title"] == title: + for date in show["date"]: + assert isinstance(date, datetime.datetime) + if date.date() == show_date: + show_times.append(date.strftime("\n - %H:%M")) + ans = resource.prompts["multiple_times_for_date"] + voice_times = " klukkan " + natlang_seq(show_times) + voice_ans = ans.format( + times=voice_times.replace("\n -", "").replace("\n", _BREAK_SSML) + ) + text_ans = ans.format(times="".join((show_times))) + ans = gen_answer( + ans.format(times=natlang_seq(show_times)).replace("dagur", "dagurinn") + ) + return (dict(answer=text_ans), text_ans, numbers_to_ordinal(voice_ans)) + # No date selected, list available dates + if resource.is_unfulfilled: + if len(dates) > 0: + ans = resource.prompts["initial"] + if date_number == 1: + ans = ans.replace("eru", "er", 1).replace( + "dagsetningar", "dagsetning", 1 + ) + voice_date_string = ( + start_string + natlang_seq(dates[index : index + date_number]) + ).replace("dagur", "dagurinn") + text_date_string = start_string + "".join( + text_dates[index : index + date_number] + ) + voice_ans = ans.format( + show=title, + dates=voice_date_string, + date_number=number_to_text(len(dates), gender="kvk"), + ).replace("\n", _BREAK_SSML) + text_ans = ans.format( + show=title, + dates=text_date_string, + date_number=len(dates), + ) + return (dict(answer=text_ans), text_ans, numbers_to_ordinal(voice_ans)) + else: + return gen_answer(resource.prompts["no_date_available"].format(show=title)) + # Date and time selected, answer with confirmation + if resource.is_fulfilled: + date = dsm.get_resource("ShowDate").data + time = dsm.get_resource("ShowTime").data + with changedlocale(category="LC_TIME"): + date_time: str = datetime.datetime.combine( + date, + time, + ).strftime("%A %d. %B klukkan %H:%M") + ans = gen_answer( + resource.prompts["confirm"] + .format(date=date_time) + .replace("dagur", "daginn") + ) + return ans + + +def _generate_seat_count_answer( + resource: NumberResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + if resource.is_unfulfilled: + return gen_answer(resource.prompts["initial"]) + # Seat count is selected, answer with confirmation + if resource.is_fulfilled: + ans = resource.prompts["confirm"] + nr_seats: int = resource.data + if nr_seats == 1: + ans = ans.replace("eru", "er") + text_ans = ans.format(seats=resource.data) + voice_ans = ans.format(seats=number_to_text(resource.data)) + return (dict(answer=text_ans), text_ans, voice_ans) + + +def _generate_row_answer( + resource: ListResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + title: str = dsm.get_resource("Show").data[0] + seats: int = dsm.get_resource("ShowSeatCount").data + # get available rows for selected show + if not result.get("available_rows"): + _add_available_rows_to_result(dsm, title, seats, result) + available_rows: list[str] = result["available_rows"] + text_available_rows: list[str] = result["text_available_rows"] + if resource.is_unfulfilled: + # No rows available for selected seat count + if len(available_rows) == 0: + dsm.set_resource_state("ShowDateTime", ResourceState.UNFULFILLED) + dsm.extras["page_index"] = 0 + ans = resource.prompts["not_enough_seats"] + if seats == 1: + ans = ans.replace("laus", "laust") + text_ans = ans.format(seats=seats) + voice_ans = ans.format(seats=number_to_text(seats)) + return (dict(answer=text_ans), text_ans, voice_ans) + # Returning initial answer with the available rows + ans = resource.prompts["initial"] + if len(available_rows) == 1: + ans = ans.replace("röðum", "röð") + if seats == 1: + ans = ans.replace("eru", "er") + text_ans = ans.format(seats=seats, seat_rows=natlang_seq(text_available_rows)) + voice_ans = ans.format( + seats=number_to_text(seats), seat_rows=natlang_seq(available_rows) + ) + return (dict(answer=text_ans), text_ans, voice_ans) + # Row has been selected, answer with confirm prompt + if resource.is_fulfilled: + row = dsm.get_resource("ShowSeatRow").data[0] + ans = resource.prompts["confirm"] + text_ans = ans.format(row=row) + voice_ans = ans.format(row=number_to_text(row)) + return (dict(answer=text_ans), text_ans, voice_ans) + + +def _generate_seat_number_answer( + resource: ListResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + title: str = dsm.get_resource("Show").data[0] + seats: int = dsm.get_resource("ShowSeatCount").data + chosen_row: int = dsm.get_resource("ShowSeatRow").data[0] + available_seats: list[str] = [] + text_available_seats: list[str] = [] + for show in _SHOWS: + if show["title"] == title: + for (row, seat) in show["location"]: + if chosen_row == row: + text_available_seats.append(str(seat)) + available_seats.append(number_to_text(seat)) + # No seat selected, list available seats + if resource.is_unfulfilled: + ans = resource.prompts["initial"] + if len(available_seats) == 1: + ans = ans.replace("eru", "er") + text_ans = ans.format(seats=natlang_seq(text_available_seats), row=chosen_row) + voice_ans = ans.format( + seats=natlang_seq(available_seats), row=number_to_text(chosen_row) + ) + return (dict(answer=text_ans), text_ans, voice_ans) + # Seat has been selected, answer with confirmation + if resource.is_fulfilled: + chosen_seats_voice_string: str = "" + chosen_seats_text_string: str = "" + + if seats > 1: + chosen_seats_voice_string = "{first_seat} til {last_seat}".format( + first_seat=number_to_text(result.get("numbers")[0]), + last_seat=number_to_text(result.get("numbers")[1]), + ) + chosen_seats_text_string = "{first_seat} til {last_seat}".format( + first_seat=result.get("numbers")[0], + last_seat=result.get("numbers")[1], + ) + else: + chosen_seats_voice_string = number_to_text(result.get("numbers")[0]) + chosen_seats_text_string = result.get("numbers")[0] + ans = resource.prompts["confirm"] + text_ans = ans.format(seats=chosen_seats_text_string) + voice_ans = ans.format(seats=chosen_seats_voice_string) + return (dict(answer=text_ans), text_ans, voice_ans) + + +def _generate_final_answer( + resource: FinalResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + if resource.is_cancelled: + return gen_answer(resource.prompts["cancelled"]) + + dsm.set_resource_state(resource.name, ResourceState.CONFIRMED) + title = dsm.get_resource("Show").data[0] + date = cast(DateResource, dsm.get_resource("ShowDate")).data + time = cast(TimeResource, dsm.get_resource("ShowTime")).data + number_of_seats = cast(NumberResource, dsm.get_resource("ShowSeatCount")).data + seats = dsm.get_resource("ShowSeatNumber").data + seat_voice_string: str = "" + seats_text_string: str = "" + if number_of_seats > 1: + seat_voice_string = "{first_seat} til {last_seat}".format( + first_seat=number_to_text(seats[0]), + last_seat=number_to_text(seats[-1]), + ) + seats_text_string = "{first_seat} til {last_seat}".format( + first_seat=seats[0], + last_seat=seats[-1], + ) + else: + seat_voice_string = number_to_text(seats[0]) + seats_text_string = seats[0] + row = dsm.get_resource("ShowSeatRow").data[0] + with changedlocale(category="LC_TIME"): + date_time_voice: str = ( + datetime.datetime.combine( + date, + time, + ) + .strftime("%A %d. %B klukkan %H:%M\n") + .replace("dagur", "daginn") + ) + date_time_text: str = datetime.datetime.combine( + date, + time, + ).strftime("%a %d. %b kl. %H:%M") + ans = resource.prompts["final"] + text_ans = ans.format( + seats=seats_text_string, row=row, show=title, date_time=date_time_text + ) + voice_ans = ans.format( + seats=seat_voice_string, + row=number_to_text(row), + show=title, + date_time=date_time_voice, + ) + return (dict(answer=text_ans), text_ans, voice_ans) + + +def QTheaterDialogue(node: Node, params: ParamList, result: Result) -> None: + if "qtype" not in result: + result.qtype = _THEATER_QTYPE + + +def QTheaterHotWord(node: Node, params: ParamList, result: Result) -> None: + result.qtype = _START_DIALOGUE_QTYPE + print("ACTIVATING THEATER MODULE") + Query.get_dsm(result).hotword_activated() + + +def QTheaterShowQuery(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + selected_show: str = result.show_name + resource: ListResource = cast(ListResource, dsm.get_resource("Show")) + show_exists = False + for show in _SHOWS: + if show["title"].lower() == selected_show.lower(): + resource.data = [show["title"]] + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + show_exists = True + break + if not show_exists: + if resource.is_unfulfilled: + dsm.set_answer(gen_answer(resource.prompts["no_show_matched"])) + # result.no_show_matched = True + if resource.is_fulfilled: + dsm.set_answer( + gen_answer( + resource.prompts["no_show_matched_data_exists"].format( + show=resource.data[0] + ) + ) + ) + # result.no_show_matched_data_exists = True + + +def QTheaterShowPrice(node: Node, params: ParamList, result: Result) -> None: + dsm = Query.get_dsm(result) + show = dsm.get_resource("Show") + if show.is_confirmed: + show = [s for s in _SHOWS if s["title"].lower() == show.data[0].lower()][0] + price = show["price"] + ans = f"Verðið fyrir einn miða á sýninguna {show['title']} er {price} kr." + + dsm.set_answer( + ( + {"answer": ans}, + ans, + numbers_to_text(ans, gender="kvk").replace("kr", "krónur"), + ) + ) + else: + dsm.set_answer(gen_answer("Þú hefur ekki valið sýningu.")) + + +def QTheaterShowLength(node: Node, params: ParamList, result: Result) -> None: + dsm = Query.get_dsm(result) + show = dsm.get_resource("Show") + if show.is_confirmed: + show = [s for s in _SHOWS if s["title"].lower() == show.data[0].lower()][0] + length = show["show_length"] + ans = f"Lengd sýningarinnar {show['title']} er {time_period_desc(length*60)}." + + dsm.set_answer(({"answer": ans}, ans, numbers_to_text(ans, gender="kvk"))) + else: + dsm.set_answer(gen_answer("Þú hefur ekki valið sýningu.")) + + +def _add_date( + resource: DateResource, dsm: DialogueStateManager, result: Result +) -> None: + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + if dsm.get_resource("Show").is_confirmed: + show_title: str = dsm.get_resource("Show").data[0] + for show in _SHOWS: + if show["title"].lower() == show_title.lower(): + for date in show["date"]: + if result["show_date"] == date.date(): + resource.data = date.date() + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + break + time_resource: TimeResource = cast(TimeResource, dsm.get_resource("ShowTime")) + dsm.set_resource_state(time_resource.name, ResourceState.UNFULFILLED) + datetime_resource: Resource = dsm.get_resource("ShowDateTime") + show_times: list[datetime.time] = [] + for show in _SHOWS: + if show["title"].lower() == show_title.lower(): + for date in show["date"]: + if resource.data == date.date(): + show_times.append(date.time()) + if len(show_times) == 0: + dsm.set_answer(gen_answer(datetime_resource.prompts["no_date_matched"])) + return + if len(show_times) == 1: + time_resource.data = show_times[0] + dsm.set_resource_state(time_resource.name, ResourceState.FULFILLED) + dsm.set_resource_state(datetime_resource.name, ResourceState.FULFILLED) + else: + dsm.set_resource_state( + datetime_resource.name, ResourceState.PARTIALLY_FULFILLED + ) + + +def _add_time( + resource: TimeResource, dsm: DialogueStateManager, result: Result +) -> None: + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + if result.get("no_date_matched"): + return + if dsm.get_resource("Show").is_confirmed: + show_title: str = dsm.get_resource("Show").data[0] + date_resource: DateResource = cast(DateResource, dsm.get_resource("ShowDate")) + datetime_resource: Resource = dsm.get_resource("ShowDateTime") + first_matching_date: Optional[datetime.datetime] = None + if date_resource.is_fulfilled: + for show in _SHOWS: + if show["title"].lower() == show_title.lower(): + for date in show["date"]: + if ( + date_resource.data == date.date() + and result["show_time"] == date.time() + ): + first_matching_date = date + resource.data = date.time() + dsm.set_resource_state( + resource.name, ResourceState.FULFILLED + ) + break + if resource.is_fulfilled: + dsm.set_resource_state(datetime_resource.name, ResourceState.FULFILLED) + else: + result.wrong_show_time = True + else: + for show in _SHOWS: + if show["title"].lower() == show_title.lower(): + for date in show["date"]: + if result["show_time"] == date.time(): + if first_matching_date is None: + first_matching_date = date + else: + dsm.set_answer( + gen_answer( + datetime_resource.prompts["many_matching_times"] + ) + ) + # result.many_matching_times = True + return + if first_matching_date is not None: + date_resource: DateResource = cast( + DateResource, dsm.get_resource("ShowDate") + ) + date_resource.data = first_matching_date.date() + dsm.set_resource_state(date_resource.name, ResourceState.FULFILLED) + resource.data = first_matching_date.time() + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + dsm.set_resource_state(datetime_resource.name, ResourceState.FULFILLED) + if first_matching_date is None: + dsm.set_answer(gen_answer(datetime_resource.prompts["no_time_matched"])) + # result.no_time_matched = True + + +def QTheaterDateTime(node: Node, params: ParamList, result: Result) -> None: + datetimenode = node.first_child(lambda n: True) + assert isinstance(datetimenode, TerminalNode) + now = datetime.datetime.now() + y, m, d, h, min, _ = (i if i != 0 else None for i in json.loads(datetimenode.aux)) + if y is None: + y = now.year + if m is None: + m = now.month + if d is None: + d = now.day + if h is None: + h = 12 + if min is None: + min = 0 + # Change before noon times to afternoon + if h < 12: + h += 12 + result["show_time"] = datetime.time(h, min) + result["show_date"] = datetime.date(y, m, d) + + dsm: DialogueStateManager = Query.get_dsm(result) + _add_date(cast(DateResource, dsm.get_resource("ShowDate")), dsm, result) + _add_time(cast(TimeResource, dsm.get_resource("ShowTime")), dsm, result) + + +def QTheaterDate(node: Node, params: ParamList, result: Result) -> None: + datenode = node.first_child(lambda n: True) + assert isinstance(datenode, TerminalNode) + cdate = datenode.contained_date + if cdate: + y, m, d = cdate + now = datetime.datetime.utcnow() + + # This is a date that contains at least month & mday + if d and m: + if not y: + y = now.year + # Bump year if month/day in the past + if m < now.month or (m == now.month and d < now.day): + y += 1 + result["show_date"] = datetime.date(day=d, month=m, year=y) + dsm: DialogueStateManager = Query.get_dsm(result) + _add_date(cast(DateResource, dsm.get_resource("ShowDate")), dsm, result) + return + raise ValueError("No date in {0}".format(str(datenode))) + + +def QTheaterTime(node: Node, params: ParamList, result: Result) -> None: + # Extract time from time terminal nodes + tnode = cast(TerminalNode, node.first_child(lambda n: n.has_t_base("tími"))) + if tnode: + aux_str = tnode.aux.strip("[]") + hour, minute, _ = (int(i) for i in aux_str.split(", ")) + # Change before noon times to afternoon + if hour < 12: + hour += 12 + + result["show_time"] = datetime.time(hour, minute) + + dsm: DialogueStateManager = Query.get_dsm(result) + _add_time(cast(TimeResource, dsm.get_resource("ShowTime")), dsm, result) + + +def QTheaterMoreDates(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.current_resource.name == "ShowDate": + extras: Dict[str, Any] = dsm.extras + if "page_index" in extras: + extras["page_index"] += 3 + else: + extras["page_index"] = 3 + + +def QTheaterPreviousDates(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.current_resource.name == "ShowDate": + extras: Dict[str, Any] = dsm.extras + if "page_index" in extras: + extras["page_index"] = max(extras["page_index"] - 3, 0) + else: + extras["page_index"] = 0 + + +def QTheaterShowSeatCountQuery(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.get_resource("ShowDateTime").is_confirmed: + resource: NumberResource = cast( + NumberResource, dsm.get_resource("ShowSeatCount") + ) + if result.number > 0: + resource.data = result.number + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + else: + dsm.set_answer(gen_answer(resource.prompts["invalid_seat_count"])) + # result.invalid_seat_count = True + + +def _add_available_rows_to_result( + dsm: DialogueStateManager, title: str, seats: int, result: Result +) -> None: + available_rows: list[str] = [] + text_available_rows: list[str] = [] + for show in _SHOWS: + if show["title"].lower() == title.lower(): + checking_row: int = 1 + seats_in_row: int = 0 + row_added: int = 0 + for (row, _) in show["location"]: + if checking_row == row and row != row_added: + seats_in_row += 1 + if seats_in_row >= seats: + available_rows.append(number_to_text(row)) + text_available_rows.append(str(row)) + seats_in_row = 0 + row_added = row + else: + checking_row = row + seats_in_row = 1 + result.available_rows = available_rows + result.text_available_rows = text_available_rows + + +def QTheaterShowRow(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.get_resource("ShowSeatCount").is_confirmed: + title: str = dsm.get_resource("Show").data[0] + seats: int = dsm.get_resource("ShowSeatCount").data + resource: ListResource = cast(ListResource, dsm.get_resource("ShowSeatRow")) + + # Adding rows to result + _add_available_rows_to_result(dsm, title, seats, result) + available_rows: list[int] = [int(i) for i in result.text_available_rows] + # No rows available + if len(available_rows) == 0: + dsm.set_resource_state("ShowDateTime", ResourceState.UNFULFILLED) + dsm.extras["page_index"] = 0 + ans = resource.prompts["not_enough_seats"] + if seats == 1: + ans = ans.replace("laus", "laust") + text_ans = ans.format(seats=seats) + voice_ans = ans.format(seats=number_to_text(seats)) + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + # Available row chosen + if result.number in available_rows: + resource.data = [result.number] + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + # Incorrect row chosen + else: + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + ans = resource.prompts["no_row_matched"] + if seats == 1: + ans = ans.replace("laus", "laust") + text_ans = ans.format(seats=seats) + voice_ans = ans.format(seats=number_to_text(seats)) + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + + +def QTheaterShowSeats(node: Node, params: ParamList, result: Result) -> None: + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.get_resource("ShowSeatRow").is_confirmed: + resource: ListResource = cast(ListResource, dsm.get_resource("ShowSeatNumber")) + title: str = dsm.get_resource("Show").data[0] + row: int = dsm.get_resource("ShowSeatRow").data[0] + number_of_seats: int = dsm.get_resource("ShowSeatCount").data + selected_seats: list[int] = [] + if len(result.numbers) > 1: + selected_seats = [ + seat for seat in range(result.numbers[0], result.numbers[1] + 1) + ] + else: + selected_seats = [result.numbers[0]] + if len(selected_seats) != number_of_seats: + resource.data = [] + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + + result.wrong_number_seats_selected = True + if len(result.get("numbers")) > 1: + chosen_seats = len( + range(result.get("numbers")[0], result.get("numbers")[1] + 1) + ) + else: + chosen_seats = 1 + ans = resource.prompts["wrong_number_seats_selected"] + text_ans = ans.format(chosen_seats=chosen_seats, seats=number_of_seats) + voice_ans = ans.format( + chosen_seats=number_to_text(chosen_seats), + seats=number_to_text(number_of_seats), + ) + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + return + for show in _SHOWS: + if show["title"].lower() == title.lower(): + seats: list[int] = [] + for seat in selected_seats: + if (row, seat) in show["location"]: + seats.append(seat) + else: + resource.data = [] + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + dsm.set_answer( + gen_answer(resource.prompts["seats_unavailable"]) + ) + # result.seats_unavailable = True + return + resource.data = [] + for seat in seats: + resource.data.append(seat) + if len(resource.data) > 0: + dsm.set_resource_state(resource.name, ResourceState.FULFILLED) + + +def QTheaterGeneralOptions(node: Node, params: ParamList, result: Result) -> None: + result.options_info = True + dsm: DialogueStateManager = Query.get_dsm(result) + resource = dsm.current_resource + if resource.name == "Show": + QTheaterShowOptions(node, params, result) + elif resource.name == "ShowDateTime": + QTheaterDateOptions(node, params, result) + elif resource.name == "ShowSeatRow": + QTheaterRowOptions(node, params, result) + elif resource.name == "ShowSeatOptions": + QTheaterSeatOptions(node, params, result) + + +def QTheaterShowOptions(node: Node, params: ParamList, result: Result) -> None: + # result.show_options = True + dsm: DialogueStateManager = Query.get_dsm(result) + if result.get("options_info"): + resource = dsm.current_resource + else: + resource = dsm.get_resource("Show") + if resource.name == "Show": + shows: list[str] = [] + for show in _SHOWS: + shows.append("\n - " + show["title"]) + ans = resource.prompts["options"] + if len(shows) == 1: + ans = ans.replace("Sýningarnar", "Sýningin", 1).replace("eru", "er", 2) + text_ans = ans.format(options="".join(shows)) + voice_ans = ans.format(options=natlang_seq(shows)).replace("-", "") + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + + +def QTheaterDateOptions(node: Node, params: ParamList, result: Result) -> None: + # result.date_options = True + dsm: DialogueStateManager = Query.get_dsm(result) + if result.get("options_info"): + resource = dsm.current_resource + else: + resource = dsm.get_resource("ShowDateTime") + if resource.name == "ShowDateTime": + title = dsm.get_resource("Show").data[0] + dates: list[str] = [] + text_dates: list[str] = [] + index: int = 0 + extras: Dict[str, Any] = dsm.extras + if "page_index" in extras: + index = extras["page_index"] + for show in _SHOWS: + if show["title"].lower() == title.lower(): + for date in show["date"]: + with changedlocale(category="LC_TIME"): + text_dates.append(date.strftime("\n - %a %d. %b kl. %H:%M")) + dates.append(date.strftime("\n%A %d. %B klukkan %H:%M")) + date_number: int = 3 if len(dates) >= 3 else len(dates) + start_string: str = ( + "Eftirfarandi dagsetning er í boði:" + if date_number == 1 + else "Næstu tvær dagsetningarnar eru:" + if date_number == 2 + else "Næstu þrjár dagsetningarnar eru:" + ) + if index == 0: + start_string = start_string.replace("Næstu", "Fyrstu", 1) + if len(dates) < 3: + index = 0 + extras["page_index"] = 0 + if index > len(dates) - 3 and len(dates) > 3: + start_string = "Síðustu þrjár dagsetningarnar eru:\n" + index = max(0, len(dates) - 3) + extras["page_index"] = index + options_string = ( + start_string + natlang_seq(dates[index : index + date_number]) + ).replace("dagur", "dagurinn") + text_options_string = start_string + "".join( + text_dates[index : index + date_number] + ) + if len(dates) > 0: + ans = resource.prompts["options"] + if date_number == 1: + ans = ans.replace("eru", "er", 1).replace( + "dagsetningar", "dagsetning", 1 + ) + voice_ans = ans.format( + options=options_string, + date_number=number_to_text(len(dates), gender="kvk"), + ).replace("\n", _BREAK_SSML) + text_ans = ans.format( + options=text_options_string, + date_number=number_to_text(len(dates), gender="kvk"), + ) + + dsm.set_answer( + (dict(answer=text_ans), text_ans, numbers_to_ordinal(voice_ans)) + ) + else: + dsm.set_answer( + gen_answer(resource.prompts["no_date_available"].format(show=title)) + ) + + +def QTheaterRowOptions(node: Node, params: ParamList, result: Result) -> None: + # result.row_options = True + dsm: DialogueStateManager = Query.get_dsm(result) + if result.get("options_info"): + resource = dsm.current_resource + else: + resource = dsm.get_resource("ShowSeatRow") + if resource.name == "ShowSeatRow": + title: str = dsm.get_resource("Show").data[0] + seats: int = dsm.get_resource("ShowSeatCount").data + available_rows: list[str] = [] + text_available_rows: list[str] = [] + for show in _SHOWS: + if show["title"].lower() == title.lower(): + checking_row: int = 1 + seats_in_row: int = 0 + row_added: int = 0 + for (row, _) in show["location"]: + if checking_row == row and row != row_added: + seats_in_row += 1 + if seats_in_row >= seats: + available_rows.append(number_to_text(row)) + text_available_rows.append(str(row)) + seats_in_row = 0 + row_added = row + else: + checking_row = row + seats_in_row = 1 + ans = resource.prompts["options"] + if len(available_rows) == 1: + ans = ans.replace("eru", "er").replace("Raðir", "Röð") + if seats == 1: + ans = ans.replace("laus", "laust") + text_ans = ans.format(rows=natlang_seq(text_available_rows), seats=seats) + voice_ans = ans.format( + rows=natlang_seq(available_rows), seats=number_to_text(seats) + ) + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + + +def QTheaterSeatOptions(node: Node, params: ParamList, result: Result) -> None: + result.seat_options = True + dsm: DialogueStateManager = Query.get_dsm(result) + if result.get("options_info"): + resource = dsm.current_resource + else: + resource = dsm.get_resource("ShowSeatNumber") + if resource.name == "ShowSeatNumber": + title: str = dsm.get_resource("Show").data[0] + chosen_row: int = dsm.get_resource("ShowSeatRow").data[0] + available_seats: list[str] = [] + text_available_seats: list[str] = [] + for show in _SHOWS: + if show["title"].lower() == title.lower(): + for (row, seat) in show["location"]: + if chosen_row == row: + text_available_seats.append(str(seat)) + available_seats.append(number_to_text(seat)) + if (not resource.is_confirmed and result.get("options_info")) or result.get( + "seat_options" + ): + ans = resource.prompts["options"] + if len(available_seats) == 1: + ans = ans.replace("Sætin", "Sætið", 1).replace("eru", "er", 2) + text_ans = ans.format( + row=chosen_row, options=natlang_seq(text_available_seats) + ) + voice_ans = ans.format( + row=number_to_text(chosen_row), options=natlang_seq(available_seats) + ) + dsm.set_answer((dict(answer=text_ans), text_ans, voice_ans)) + + +def QTheaterShowName(node: Node, params: ParamList, result: Result) -> None: + result.show_name = ( + " ".join(result._text.split()[1:]) + if result._text.startswith("sýning") + else result._text + ) + + +def QTheaterNum(node: Node, params: ParamList, result: Result): + number: int = int(parse_num(node, result._nominative)) + if "numbers" not in result: + result["numbers"] = [] + result.numbers.append(number) + result.number = number + + +QTheaterSeatCountNum = QTheaterNum +QTheaterRowNum = QTheaterNum +QTheaterShowSeatsNum = QTheaterNum + + +def QTheaterCancel(node: Node, params: ParamList, result: Result): + dsm: DialogueStateManager = Query.get_dsm(result) + dsm.set_resource_state("Final", ResourceState.CANCELLED) + dsm.set_answer(gen_answer(dsm.get_resource("Final").prompts["cancelled"])) + dsm.finish_dialogue() + + result.qtype = "QTheaterCancel" + + +def QTheaterYes(node: Node, params: ParamList, result: Result): + dsm: DialogueStateManager = Query.get_dsm(result) + current_resource = dsm.current_resource + if ( + not current_resource.is_confirmed + and current_resource.name + in ( + "Show", + "ShowDateTime", + "ShowSeatCount", + "ShowSeatRow", + "ShowSeatNumber", + ) + ) and current_resource.is_fulfilled: + dsm.set_resource_state(current_resource.name, ResourceState.CONFIRMED) + if isinstance(current_resource, WrapperResource): + for rname in current_resource.requires: + dsm.get_resource(rname).state = ResourceState.CONFIRMED + + +def QTheaterNo(node: Node, params: ParamList, result: Result): + dsm: DialogueStateManager = Query.get_dsm(result) + resource = dsm.current_resource + if ( + not resource.is_confirmed + and resource.name + in ( + "Show", + "ShowDateTime", + "ShowSeatCount", + "ShowSeatRow", + "ShowSeatNumber", + ) + ) and resource.is_fulfilled: + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + if isinstance(resource, WrapperResource): + for rname in resource.requires: + dsm.set_resource_state(rname, ResourceState.UNFULFILLED) + + +def QTheaterStatus(node: Node, params: ParamList, result: Result): + # TODO: Handle QTheaterStatus again with dsm in query + result.qtype = "QTheaterStatus" + dsm: DialogueStateManager = Query.get_dsm(result) + at = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + if at: + (_, ans, voice) = at + ans = "Leikhúsmiðapöntunin þín gengur bara vel. " + ans + voice = "Leikhúsmiðapöntunin þín gengur bara vel. " + voice + dsm.set_answer((dict(answer=ans), ans, voice)) + + +# SHOW_URL = "https://leikhusid.is/wp-json/shows/v1/categories/938" + + +# def _fetch_shows() -> Any: +# resp = query_json_api(SHOW_URL) +# if resp: +# assert isinstance(resp, list) +# return [s["title"] for s in resp] + + +_ANSWERING_FUNCTIONS: AnsweringFunctionMap = { + "Show": _generate_show_answer, + "ShowDateTime": _generate_date_answer, + "ShowSeatCount": _generate_seat_count_answer, + "ShowSeatRow": _generate_row_answer, + "ShowSeatNumber": _generate_seat_number_answer, + "Final": _generate_final_answer, +} + + +def sentence(state: QueryStateDict, result: Result) -> None: + """Called when sentence processing is complete""" + q: Query = state["query"] + dsm: DialogueStateManager = q.dsm + if dsm.not_in_dialogue(): + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + try: + print("A") + # result.shows = _fetch_shows() + # dsm.setup_dialogue(_ANSWERING_FUNCTIONS) + # if result.qtype == "QTheaterStatus": + # # Example info handling functionality + # text = "Leikhúsmiðapöntunin þín gengur bara vel. " + # ans = dsm.get_answer() or gen_answer(text) + # q.set_answer(*ans) + # return + # print("C") + # print(dsm._resources) + ans: Optional[AnswerTuple] = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + if "show_options" not in result: + q.query_is_command() + print("D", ans) + if not ans: + print("No answer generated") + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + q.set_qtype(result.qtype) + q.set_answer(*ans) + except Exception as e: + logging.warning("Exception while processing random query: {0}".format(e)) + q.set_error("E_EXCEPTION: {0}".format(e)) + raise diff --git a/queries/examples/grammar.py b/queries/examples/grammar.py index 5b9bc215..50808fd2 100755 --- a/queries/examples/grammar.py +++ b/queries/examples/grammar.py @@ -29,7 +29,7 @@ import random from queries import QueryStateDict -from tree import Node, Result +from tree import Node, Result, ParamList # Indicate that this module wants to handle parse trees for queries, @@ -70,16 +70,16 @@ def help_text(lemma: str) -> str: """ -def QGrammarExampleQuery(node: Node, params: QueryStateDict, result: Result): +def QGrammarExampleQuery(node: Node, params: ParamList, result: Result): # Set the query type result.qtype = "GrammarTest" -def QGrammarExampleTest(node: Node, params: QueryStateDict, result: Result): +def QGrammarExampleTest(node: Node, params: ParamList, result: Result): result.qkey = "Test" -def QGrammarExampleNotTest(node: Node, params: QueryStateDict, result: Result): +def QGrammarExampleNotTest(node: Node, params: ParamList, result: Result): result.qkey = "NotTest" diff --git a/queries/extras/dialogue.py b/queries/extras/dialogue.py new file mode 100644 index 00000000..be6c3ad1 --- /dev/null +++ b/queries/extras/dialogue.py @@ -0,0 +1,914 @@ +""" + + Greynir: Natural language processing for Icelandic + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + Class for dialogue management. + +""" +from typing import ( + Any, + Callable, + Dict, + Iterable, + Mapping, + Set, + List, + Optional, + Tuple, + Type, + Union, + cast, +) +from typing_extensions import Required, TypedDict + +import json +import logging +import datetime +from pathlib import Path +from functools import lru_cache + +try: + import tomllib # type: ignore (module not available in Python <3.11) +except ModuleNotFoundError: + import tomli as tomllib # Used for Python <3.11 + +from db import SessionContext, Session +from db.models import DialogueData as DB_DialogueData, QueryData as DB_QueryData + +import queries.extras.resources as res +from queries import AnswerTuple + + +# TODO:? Delegate answering from a resource to another resource or to another dialogue +# TODO:? í ávaxtasamtali "ég vil panta flug" "viltu að ég geymi ávaxtapöntunina eða eyði henni?" ... +# TODO: Add specific prompt handling to DSM to remove result from DSM. +# TODO: Add try-except blocks where appropriate +# TODO: Add "needs_confirmation" to TOML files (skip fulfilled, go straight to confirmed) + +_TOML_FOLDER_NAME = "dialogues" +_DEFAULT_EXPIRATION_TIME = 30 * 60 # By default a dialogue expires after 30 minutes +_FINAL_RESOURCE_NAME = "Final" + +_JSONTypes = Union[None, int, bool, str, List["_JSONTypes"], Dict[str, "_JSONTypes"]] +_TOMLTypes = Union[ + int, + float, + bool, + str, + datetime.datetime, + datetime.date, + datetime.time, + List["_TOMLTypes"], + Dict[str, "_TOMLTypes"], +] + + +class _ExtrasType(Dict[str, _TOMLTypes], TypedDict, total=False): + """Structure of 'extras' key in dialogue TOML files.""" + + expiration_time: int + + +class DialogueTOMLStructure(TypedDict): + """Structure of a dialogue TOML file.""" + + resources: Required[List[Dict[str, _TOMLTypes]]] + dynamic_resources: List[Dict[str, _TOMLTypes]] + extras: _ExtrasType + + +# Keys for accessing saved client data for dialogues +_ACTIVE_DIALOGUE_KEY = "dialogue" +_RESOURCES_KEY = "resources" +_DYNAMIC_RESOURCES_KEY = "dynamic_resources" +_MODIFIED_KEY = "modified" +_EXTRAS_KEY = "extras" +_EXPIRATION_TIME_KEY = "expiration_time" + +# List of active dialogues, kept in querydata table +# (newer dialogues have higher indexes) +ActiveDialogueList = List[Tuple[str, str]] + + +class SerializedResource(Dict[str, _JSONTypes], TypedDict, total=False): + """ + Representation of the required keys of a serialized resource. + """ + + name: Required[str] + type: Required[str] + state: Required[int] + + +class DialogueDeserialized(TypedDict): + """ + Representation of the dialogue structure, + after it is loaded from the database and parsed. + """ + + resources: Iterable[res.Resource] + extras: _ExtrasType + + +class DialogueSerialized(TypedDict): + """ + Representation of the dialogue structure, + before it is saved to the database. + """ + + resources: Iterable[SerializedResource] + extras: str + + +class DialogueDataRow(TypedDict): + data: DialogueSerialized + expires_at: datetime.datetime + + +def _dialogue_serializer(data: DialogueDeserialized) -> DialogueSerialized: + """ + Prepare the dialogue data for writing into the database. + """ + return { + _RESOURCES_KEY: [ + cast(SerializedResource, res.RESOURCE_SCHEMAS[s.type]().dump(s)) + for s in data[_RESOURCES_KEY] + ], + # We just dump the entire extras dict as a string + _EXTRAS_KEY: json.dumps(data[_EXTRAS_KEY], cls=TOMLtoJSONEncoder), + } + + +def _dialogue_deserializer(data: DialogueSerialized) -> DialogueDeserialized: + """ + Prepare the dialogue data for working with + after it has been loaded from the database. + """ + return { + _RESOURCES_KEY: [ + cast(res.Resource, res.RESOURCE_SCHEMAS[s["type"]]().load(s)) + for s in data[_RESOURCES_KEY] + ], + # Load the extras dictionary from a JSON serialized string + _EXTRAS_KEY: json.loads(data[_EXTRAS_KEY], cls=JSONtoTOMLDecoder), + } + + +class TOMLtoJSONEncoder(json.JSONEncoder): + # Map TOML type to a JSON serialized form + _serializer_functions: Mapping[Type[Any], Callable[[Any], _JSONTypes]] = { + datetime.datetime: lambda o: { + "__type__": "datetime", + "year": o.year, + "month": o.month, + "day": o.day, + "hour": o.hour, + "minute": o.minute, + "second": o.second, + "microsecond": o.microsecond, + }, + datetime.date: lambda o: { + "__type__": "date", + "year": o.year, + "month": o.month, + "day": o.day, + }, + datetime.time: lambda o: { + "__type__": "time", + "hour": o.hour, + "minute": o.minute, + "second": o.second, + "microsecond": o.microsecond, + }, + } + + def default(self, o: _TOMLTypes) -> _JSONTypes: + f = self._serializer_functions.get(type(o)) + return f(o) if f else json.JSONEncoder.default(self, o) + + +class JSONtoTOMLDecoder(json.JSONDecoder): + # Map __type__ to nonserialized form + _type_conversions: Mapping[str, Type[_TOMLTypes]] = { + "datetime": datetime.datetime, + "date": datetime.date, + "time": datetime.time, + } + + def __init__(self, *args: Any, **kwargs: Any): + json.JSONDecoder.__init__( + self, object_hook=self.dialogue_decoding, *args, **kwargs + ) + + def dialogue_decoding(self, d: Dict[str, Any]) -> _TOMLTypes: + if "__type__" not in d: + return d + t: str = d.pop("__type__") + + c = self._type_conversions.get(t) + if c: + return c(**d) + logging.warning(f"No class found for __type__: {t}") + d["__type__"] = t + return d + + +# Functions for generating prompts/answers +# Arguments: resource, DSM, result object +_AnsweringFunctionType = Callable[..., Optional[AnswerTuple]] + +# Difficult to type this correctly as the +# Callable type is contravariant in its arguments parameter +AnsweringFunctionMap = Mapping[str, _AnsweringFunctionType] + +# Filter functions for filtering nodes +# when searching resource graph +FilterFuncType = Callable[[res.Resource, int], bool] +_ALLOW_ALL_FILTER: FilterFuncType = lambda r, i: True + + +class ResourceGraphItem(TypedDict): + """Type for a node in the resource graph.""" + + children: List[res.Resource] + parents: List[res.Resource] + + +# Dependency relationship graph type for resources +ResourceGraph = Dict[res.Resource, ResourceGraphItem] + + +################################ +# DIALOGUE STATE MANAGER # +################################ + + +class DialogueStateManager: + def __init__(self, client_id: Optional[str], db_session: Session) -> None: + """Initialize DSM instance and fetch the active dialogues for a client.""" + self._client_id = client_id + self._db_session = db_session # Database session of parent Query class + # Fetch active dialogues for this client (empty list if no client ID provided) + self._active_dialogues: ActiveDialogueList = self._get_active_dialogues() + + def get_next_active_resource(self, dialogue_name: str) -> str: + """ + Fetch the next current resource for a given dialogue. + Used for banning nonterminals. + """ + for x, y in self._active_dialogues: + if x == dialogue_name: + return y + raise ValueError( + "get_last_active_resource called " + f"for non-active dialogue: {dialogue_name}" + ) + + def prepare_dialogue(self, dialogue_name: str): + """ + Prepare DSM instance for a specific dialogue. + Fetches saved state from database if dialogue is active. + """ + self._dialogue_name: str = dialogue_name + # Dict mapping resource name to resource instance + self._resources: Dict[str, res.Resource] = {} + # Boolean indicating if the client is in this dialogue + self._in_this_dialogue: bool = False + # Extra information saved with the dialogue state + self._extras: _ExtrasType = {} + # Answer for the current query + self._answer_tuple: Optional[AnswerTuple] = None + # Latest non-confirmed resource + self._current_resource: Optional[res.Resource] = None + # Dependency graph for the resources + self._resource_graph: ResourceGraph = {} + # Whether this dialogue is finished (successful/cancelled) or not + self._finished: bool = False + self._expiration_time: int = _DEFAULT_EXPIRATION_TIME + self._timed_out: bool = False + self._initial_resource = None + + # If dialogue is active, the saved state is loaded, + # otherwise wait for hotword_activated() to be called + if self._dialogue_name in (x for x, _ in self._active_dialogues): + print("loading saved state...") + self._in_this_dialogue = True + self._load_saved_state() + print("done preparing dialogue!") + + @lru_cache(maxsize=30) + def _read_toml_file(self, dialogue_name: str) -> DialogueTOMLStructure: + """Read TOML file for given dialogue.""" + p = ( + Path(__file__).parent.parent.resolve() + / _TOML_FOLDER_NAME + / f"{dialogue_name}.toml" + ) + f = p.read_text() + + obj: DialogueTOMLStructure = tomllib.loads(f) # type: ignore + return obj + + def _initialize_resources(self) -> None: + """ + Loads dialogue structure from TOML file and + fills self._resources with empty Resource instances. + """ + print("Reading TOML file...") + # Read TOML file containing a list of resources for the dialogue + obj: DialogueTOMLStructure = self._read_toml_file(self._dialogue_name) + assert ( + _RESOURCES_KEY in obj + ), f"No resources found in TOML file {self._dialogue_name}.toml" + print("creating resources...") + # Create resource instances from TOML data and return as a dict + for i, resource in enumerate(obj[_RESOURCES_KEY]): + assert "name" in resource, f"Name missing for resource {i+1}" + if "type" not in resource: + resource["type"] = "Resource" + # Create instances of Resource classes (and its subclasses) + # TODO: Maybe fix the type hinting + self._resources[resource["name"]] = res.RESOURCE_MAP[resource["type"]]( # type: ignore + **resource, order_index=i + ) + print(f"Resources created: {self._resources}") + # TODO: Create dynamic resource blueprints (factory)!!!!! + self._extras = obj.get(_EXTRAS_KEY, dict()) + # Get expiration time duration for this dialogue + self._expiration_time = self._extras.get( + _EXPIRATION_TIME_KEY, _DEFAULT_EXPIRATION_TIME + ) + # Create resource dependency relationship graph + self._initialize_resource_graph() + + def _load_saved_state(self) -> None: + """ + Fetch saved data from database for this + dialogue and restore resource class instances. + """ + saved_row = self._dialogue_data() + assert saved_row is not None + self._timed_out = datetime.datetime.now() > saved_row["expires_at"] + if self._timed_out: + # TODO: Do something when a dialogue times out + logging.warning("THIS DIALOGUE IS TIMED OUT!!!") + return + saved_state: DialogueDeserialized = _dialogue_deserializer(saved_row["data"]) + # Load resources from saved state + self._resources = {r.name: r for r in saved_state["resources"]} + self._extras = saved_state["extras"] + + # Create resource dependency relationship graph + self._initialize_resource_graph() + + def _initialize_resource_graph(self) -> None: + """ + Initializes the resource graph with each + resource having children and parents according + to what each resource requires. + """ + print("Creating resource graph...") + for resource in self._resources.values(): + if resource.order_index == 0 and self._initial_resource is None: + self._initial_resource = resource + self._resource_graph[resource] = {"children": [], "parents": []} + for resource in self._resources.values(): + for req in resource.requires: + self._resource_graph[self._resources[req]]["parents"].append(resource) + self._resource_graph[resource]["children"].append(self._resources[req]) + print("Finished resource graph!") + + def add_dynamic_resource(self, resource_name: str, parent_name: str) -> None: + """ + Adds a dynamic resource to the dialogue from TOML file and + updates the requirements of it's parents. + """ + raise NotImplementedError() + # TODO: Create separate blueprint factory class for creating dynamic resources + parent_resource: res.Resource = self.get_resource(parent_name) + order_index: int = parent_resource.order_index + dynamic_resources: Dict[str, res.Resource] = {} + # Index of dynamic resource + dynamic_resource_index = ( + len( + [ + i + for i in self._resources + if self.get_resource(i).name.startswith(resource_name + "_") + ] + ) + + 1 + ) + print("<<<<<<<< DYNAMIC INDEX: ", dynamic_resource_index) + # TODO: Only update index for added dynamic resources (don't loop through all, just the added ones) + # Adding all dynamic resources to a list + for dynamic_resource in obj[_DYNAMIC_RESOURCES_KEY]: + assert "name" in dynamic_resource, f"Name missing for dynamic resource" + if "type" not in dynamic_resource: + dynamic_resource["type"] = "Resource" + # Updating required resources to have indexed name + dynamic_resource["requires"] = [ + f"{r}_{dynamic_resource_index}" + for r in dynamic_resource.get("requires", []) + ] + # Updating dynamic resource name to have indexed name + dynamic_resource["name"] = ( + f"{dynamic_resource['name']}_" f"{dynamic_resource_index}" + ) + # Adding dynamic resource to list + dynamic_resources[dynamic_resource["name"]] = res.RESOURCE_MAP[ + dynamic_resource["type"] + ]( + **dynamic_resource, + order_index=order_index, + ) + # Indexed resource name of the dynamic resource + indexed_resource_name = f"{resource_name}_{dynamic_resource_index}" + resource: res.Resource = dynamic_resources[indexed_resource_name] + # Appending resource to required list of parent resource + parent_resource.requires.append(indexed_resource_name) + + def _add_child_resource(resource: res.Resource) -> None: + """ + Recursively adds a child resource to the resources list + """ + self._resources[resource.name] = resource + for req in resource.requires: + _add_child_resource(dynamic_resources[req]) + + _add_child_resource(resource) + # Initialize the resource graph again with the update resources + self._initialize_resource_graph() + self._find_current_resource() + + def duplicate_dynamic_resource(self, original: res.Resource) -> None: + raise NotImplementedError() + suffix = ( + len( + [ + i + for i in self._resources + if self.get_resource(i).name.startswith( + original.name.split("_")[0] + "_" + ) + ] + ) + + 1 + ) + + def _recursive_deep_copy(resource: res.Resource) -> None: + nonlocal suffix, self + new_resource = res.RESOURCE_MAP[resource.type](**resource.__dict__) + prefix = "_".join(new_resource.name.split("_")[:-1]) + new_resource.name = prefix + f"_{suffix}" + new_resource.requires = [ + "_".join(rn.split("_")[:-1]) + f"_{suffix}" + for rn in new_resource.requires + ] + self._resources[new_resource.name] = new_resource + print("!!!!!!New resource: ", new_resource.__dict__) + for child in self._resource_graph[resource]["children"]: + _recursive_deep_copy(child) + + for parent in self._resource_graph[original]["parents"]: + parent.requires.append(f"Pizza_{suffix}") + + _recursive_deep_copy(original) + # Initialize the resource graph again with the update resources + self._initialize_resource_graph() + self._find_current_resource() + + def hotword_activated(self) -> None: + # TODO: Add some checks if we accidentally go into this while the dialogue is ongoing + self._in_this_dialogue = True + # Set up resources for working with them + self._initialize_resources() + # Set dialogue as newest active dialogue + self._active_dialogues.append((self._dialogue_name, self.current_resource.name)) + + def pause_dialogue(self) -> None: + ... # TODO + + def resume_dialogue(self) -> None: + ... # TODO + + def not_in_dialogue(self) -> bool: + """Check if the client is in or wants to start this dialogue""" + return not self._in_this_dialogue + + @property + def dialogue_name(self) -> Optional[str]: + if hasattr(self, "_dialogue_name"): + return self._dialogue_name + return None + + @property + def active_dialogue(self) -> Optional[str]: + return self._active_dialogues[-1][0] if self._active_dialogues else None + + @property + def current_resource(self) -> res.Resource: + if self._current_resource is None: + self._find_current_resource() + assert self._current_resource is not None + return self._current_resource + + def get_resource(self, name: str) -> res.Resource: + return self._resources[name] + + @property + def extras(self) -> Dict[str, Any]: + return self._extras + + @property + def timed_out(self) -> bool: + return self._timed_out + + def get_descendants( + self, resource: res.Resource, filter_func: Optional[FilterFuncType] = None + ) -> List[res.Resource]: + """ + Given a resource and an optional filter function + (with a resource and the depth in tree as args, returns a boolean), + returns all descendants of the resource that match the function + (all of them if filter_func is None). + Returns the descendants in preorder + """ + descendants: List[res.Resource] = [] + + def _recurse_descendants( + resource: res.Resource, depth: int, filter_func: FilterFuncType + ) -> None: + nonlocal descendants + for child in self._resource_graph[resource]["children"]: + if filter_func(child, depth): + descendants.append(child) + _recurse_descendants(child, depth + 1, filter_func) + + _recurse_descendants(resource, 0, filter_func or _ALLOW_ALL_FILTER) + return descendants + + def get_children(self, resource: res.Resource) -> List[res.Resource]: + """Given a resource, returns all children of the resource""" + return self._resource_graph[resource]["children"] + + def get_ancestors( + self, resource: res.Resource, filter_func: Optional[FilterFuncType] = None + ) -> List[res.Resource]: + """ + Given a resource and an optional filter function + (with a resource and the depth in tree as args, returns a boolean), + returns all ancestors of the resource that match the function + (all of them if filter_func is None). + """ + ancestors: List[res.Resource] = [] + + def _recurse_ancestors( + resource: res.Resource, depth: int, filter_func: FilterFuncType + ) -> None: + nonlocal ancestors + for parent in self._resource_graph[resource]["parents"]: + if filter_func(parent, depth): + ancestors.append(parent) + _recurse_ancestors(parent, depth + 1, filter_func) + + _recurse_ancestors(resource, 0, filter_func or _ALLOW_ALL_FILTER) + return ancestors + + def get_parents(self, resource: res.Resource) -> List[res.Resource]: + """Given a resource, returns all parents of the resource""" + return self._resource_graph[resource]["parents"] + + def get_answer( + self, answering_functions: AnsweringFunctionMap, result: Any + ) -> Optional[AnswerTuple]: + if self._answer_tuple is not None: + return self._answer_tuple + self._find_current_resource() + assert self._current_resource is not None + self._answering_functions = answering_functions + + # Check if dialogue was cancelled # TODO: Change this (have separate cancel method) + if self._current_resource.is_cancelled: + self._answer_tuple = self._answering_functions[_FINAL_RESOURCE_NAME]( + self._current_resource, self, result + ) + if not self._answer_tuple: + raise ValueError("No answer for cancelled dialogue") + return self._answer_tuple + + resource_name = self._current_resource.name.split("_")[0] + if resource_name in self._answering_functions: + print("GENERATING ANSWER FOR ", resource_name) + ans = self._answering_functions[resource_name]( + self._current_resource, self, result + ) + return ans + # Iterate through resources (postorder traversal) + # until one generates an answer + self._answer_tuple = self._get_answer(self._current_resource, result, set()) + + return self._answer_tuple + + # TODO: Can we remove this function? + def _get_answer( + self, curr_resource: res.Resource, result: Any, finished: Set[res.Resource] + ) -> Optional[AnswerTuple]: + for resource in self._resource_graph[curr_resource]["children"]: + if resource not in finished: + finished.add(resource) + ans = self._get_answer(resource, result, finished) + if ans: + return ans + if curr_resource.name in self._answering_functions: + return self._answering_functions[curr_resource.name]( + curr_resource, self, result + ) + return None + + def set_answer(self, answer: AnswerTuple) -> None: + self._answer_tuple = answer + + def set_resource_state(self, resource_name: str, state: res.ResourceState): + """ + Set the state of a resource. + Sets state of all parent resources to unfulfilled + if cascade_state is set to True for the resource. + """ + resource = self._resources[resource_name] + lowered_state = resource.state > state + resource.state = state + if state == res.ResourceState.FULFILLED and not resource.needs_confirmation: + resource.state = res.ResourceState.CONFIRMED + return + if resource.cascade_state and lowered_state: + # Find all parent resources and set to corresponding state + ancestors = set(self.get_ancestors(resource)) + for anc in ancestors: + anc.state = res.ResourceState.UNFULFILLED + + def _find_current_resource(self) -> None: + """ + Finds the current resource in the resource graph + using a postorder traversal of the resource graph. + """ + curr_res: Optional[res.Resource] = None + finished_resources: Set[res.Resource] = set() + + def _recurse_resources(resource: res.Resource) -> None: + nonlocal curr_res, finished_resources + finished_resources.add(resource) + if resource.is_confirmed or resource.is_skipped: + # Don't set resource as current if it is confirmed or skipped + return + # Current resource is neither confirmed nor skipped, + # so we try to find candidates lower in the tree first + for child in self._resource_graph[resource]["children"]: + if child not in finished_resources: + _recurse_resources(child) # TODO: Unwrap recursion? + if curr_res is not None: + # Found a suitable resource, stop looking + return + curr_res = resource + while not curr_res.prefer_over_wrapper: + wrapper_parents = [ + par + for par in self._resource_graph[curr_res]["parents"] + if isinstance(par, res.WrapperResource) + ] + assert ( + len(wrapper_parents) <= 1 + ), "A resource cannot have more than one wrapper parent" + if wrapper_parents: + curr_res = wrapper_parents[0] + else: + break + + _recurse_resources(self._resources[_FINAL_RESOURCE_NAME]) + if curr_res is not None: + print("CURRENT RESOURCE IN FIND CURRENT RESOURCE: ", curr_res.name) + self._current_resource = curr_res or self._resources[_FINAL_RESOURCE_NAME] + + # TODO: Can we move this function into set_resource_state? + def skip_other_resources( + self, or_resource: res.OrResource, resource: res.Resource + ) -> None: + """Skips other resources in the or resource""" + # TODO: Check whether OrResource is exclusive or not + assert isinstance( + or_resource, res.OrResource + ), f"{or_resource} is not an OrResource" + for r in or_resource.requires: + if r != resource.name: + self.set_resource_state(r, res.ResourceState.SKIPPED) + + # TODO: Can we move this function into set_resource_state? + def update_wrapper_state(self, wrapper: res.WrapperResource) -> None: + """ + Updates the state of the wrapper resource + based on the state of its children. + """ + if wrapper.state == res.ResourceState.UNFULFILLED: + print("Wrapper is unfulfilled") + if all( + [ + child.state == res.ResourceState.UNFULFILLED + for child in self._resource_graph[wrapper]["children"] + ] + ): + print("All children are unfulfilled") + return + print("At least one child is fulfilled") + self.set_resource_state(wrapper.name, res.ResourceState.PARTIALLY_FULFILLED) + if wrapper.state == res.ResourceState.PARTIALLY_FULFILLED: + print("Wrapper is partially fulfilled") + if any( + [ + child.state != res.ResourceState.CONFIRMED + for child in self._resource_graph[wrapper]["children"] + ] + ): + print("At least one child is not confirmed") + self.set_resource_state( + wrapper.name, res.ResourceState.PARTIALLY_FULFILLED + ) + return + print("All children are confirmed") + self.set_resource_state(wrapper.name, res.ResourceState.FULFILLED) + + def finish_dialogue(self) -> None: + """Set the dialogue as finished.""" + self._finished = True + + def serialize_data(self) -> Dict[str, Optional[str]]: + """Serialize the dialogue's data for saving to database""" + if self._resources[_FINAL_RESOURCE_NAME].is_confirmed: + # When final resource is confirmed, the dialogue is over + self.finish_dialogue() + ds_json: Optional[str] = None + if not self._finished and not self._timed_out: + print("!!!!!!!!!!!!!!!Serializing data! with resources: ", self._resources) + ds_json = json.dumps( + { + _RESOURCES_KEY: self._resources, + _EXTRAS_KEY: self._extras, + }, + ) + # Wrap data before saving dialogue state into client data + # (due to custom JSON serialization) + cd: Dict[str, Optional[str]] = {self._dialogue_name: ds_json} + return cd + + ################################ + # Database functions # + ################################ + + def _get_active_dialogues(self) -> ActiveDialogueList: + """Get list of active dialogues from database for current client.""" + active: ActiveDialogueList = [] + + if self._client_id: + with SessionContext(session=self._db_session, read_only=True) as session: + try: + row: Optional[DB_QueryData] = ( + session.query(DB_QueryData) + .filter(DB_QueryData.client_id == self._client_id) # type: ignore + .filter(DB_QueryData.key == _ACTIVE_DIALOGUE_KEY) + ).one_or_none() + if row is not None: + active = cast(ActiveDialogueList, row.data) + except Exception as e: + logging.error( + "Error fetching client '{0}' query data for key '{1}' from db: {2}".format( + self._client_id, _ACTIVE_DIALOGUE_KEY, e + ) + ) + return active + + def _dialogue_data(self) -> Optional[DialogueDataRow]: + """ + Fetch client_id-associated dialogue data stored + in the dialoguedata table based on the dialogue key. + """ + assert ( + self._client_id and self._dialogue_name + ), "_dialogue_data() called without client ID or dialogue name!" + + with SessionContext(session=self._db_session, read_only=True) as session: + try: + row: Optional[DB_DialogueData] = ( + session.query(DB_DialogueData) + .filter(DB_DialogueData.dialogue_key == self._dialogue_name) # type: ignore + .filter(DB_DialogueData.client_id == self._client_id) + ).one_or_none() + if row: + return { + "data": cast(DialogueSerialized, row.data), + "expires_at": cast(datetime.datetime, row.expires_at), + } + except Exception as e: + logging.error( + "Error fetching client '{0}' dialogue data for key '{1}' from db: {2}".format( + self._client_id, self._dialogue_name, e + ) + ) + return None + + def update_dialogue_data(self) -> None: + """ + Save current state of dialogue to dialoguedata table in database, + along with updating list of active dialogues in querydata table. + """ + if not self._client_id or not self._dialogue_name: + # Need both client ID and dialogue name to save any state + return + + now = datetime.datetime.now() + expires_at = now + datetime.timedelta(seconds=self._expiration_time) + with SessionContext(session=self._db_session, commit=True) as session: + try: + existing_dd_row: Optional[DB_DialogueData] = session.get( # type: ignore + DB_DialogueData, (self._client_id, self._dialogue_name) + ) + # Write data to dialoguedata table + if existing_dd_row: + # UPDATE existing row + existing_dd_row.modified = now # type: ignore + existing_dd_row.data = _dialogue_serializer( # type: ignore + { + _RESOURCES_KEY: self._resources.values(), + _EXTRAS_KEY: self._extras, + } + ) + existing_dd_row.expires_at = expires_at # type: ignore + else: + # INSERT new row + dialogue_row = DB_DialogueData( + client_id=self._client_id, + dialogue_key=self._dialogue_name, + created=now, + modified=now, + data=_dialogue_serializer( + { + _RESOURCES_KEY: self._resources.values(), + _EXTRAS_KEY: self._extras, + } + ), + expires_at=expires_at, + ) + session.add(dialogue_row) # type: ignore + except Exception as e: + logging.error( + "Error upserting client '{0}' dialogue data for key '{1}' into db: {2}".format( + self._client_id, self._dialogue_name, e + ) + ) + try: + # Write active dialogues to querydata table + existing_qd_row: Optional[DB_QueryData] = session.get( # type: ignore + DB_QueryData, (self._client_id, _ACTIVE_DIALOGUE_KEY) + ) + if existing_qd_row: + # TODO: Move this into some prettier place + # Make sure the (dialogue name, current resource) pair is up to date for this dialogue + self._active_dialogues = [ + (x, y) + if x != self._dialogue_name + else (x, self.current_resource.name) + for x, y in self._active_dialogues + ] + # UPDATE existing row + existing_qd_row.data = self._active_dialogues # type: ignore + existing_qd_row.modified = now # type: ignore + else: + # INSERT new row + querydata_row = DB_QueryData( + client_id=self._client_id, + key=_ACTIVE_DIALOGUE_KEY, + created=now, + modified=now, + data=self._active_dialogues, + ) + session.add(querydata_row) # type: ignore + except Exception as e: + logging.error( + "Error upserting client '{0}' dialogue data for key '{1}' into db: {2}".format( + self._client_id, self._dialogue_name, e + ) + ) + return diff --git a/queries/extras/resources.py b/queries/extras/resources.py new file mode 100644 index 00000000..a4ee8163 --- /dev/null +++ b/queries/extras/resources.py @@ -0,0 +1,347 @@ +""" + + Greynir: Natural language processing for Icelandic + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + Collection of resource types for dialogues. + Resources are slots for information extracted from dialogue. + +""" +from typing import ( + Any, + Dict, + Mapping, + List, + MutableMapping, + Optional, + Type, +) + +import datetime +from enum import IntFlag, auto +from dataclasses import dataclass, field as data_field +from marshmallow import Schema, fields, post_load + + +class ResourceState(IntFlag): + """Enum representing the different states a dialogue resource can be in.""" + + # Main states (order matters, lower state should equal a lower number) + UNFULFILLED = auto() + PARTIALLY_FULFILLED = auto() + FULFILLED = auto() + CONFIRMED = auto() + # ---- Extra states + PAUSED = auto() + SKIPPED = auto() + CANCELLED = auto() + ALL = ( + UNFULFILLED + | PARTIALLY_FULFILLED + | FULFILLED + | CONFIRMED + | PAUSED + | SKIPPED + | CANCELLED + ) + + +# Map resource name to type (for encoding/decoding) +RESOURCE_MAP: MutableMapping[str, Type["Resource"]] = {} +RESOURCE_SCHEMAS: MutableMapping[str, Type["ResourceSchema"]] = {} + +########################## +# RESOURCE CLASSES # +########################## + + +@dataclass(eq=False, repr=False) +class Resource: + """ + Base class representing a dialogue resource. + Keeps track of the state of the resource, and the data it contains. + """ + + # Name of resource + name: str = "" + # Type (child class) of Resource + type: str = "Resource" + # Contained data + data: Any = None + # Resource state (unfulfilled, partially fulfilled, etc.) + state: ResourceState = ResourceState.UNFULFILLED + # Resources that must be confirmed before moving on to this resource + requires: List[str] = data_field(default_factory=list) + # Dictionary containing different prompts/responses + prompts: Mapping[str, str] = data_field(default_factory=dict) + # When this resource's state is changed, change all parent resource states as well + cascade_state: bool = False + # When set to True, this resource will be used + # as the current resource instead of its wrapper + prefer_over_wrapper: bool = False + # When set to True, this resource will need + # to be confirmed before moving on to the next resource + needs_confirmation: bool = False + # Used for comparing states (which one is earlier/later in the dialogue) + order_index: int = 0 + # Extra variables to be used for specific situations + extras: Dict[str, Any] = data_field(default_factory=dict) + + @property + def is_unfulfilled(self) -> bool: + return ResourceState.UNFULFILLED in self.state + + @property + def is_partially_fulfilled(self) -> bool: + return ResourceState.PARTIALLY_FULFILLED in self.state + + @property + def is_fulfilled(self) -> bool: + return ResourceState.FULFILLED in self.state + + @property + def is_confirmed(self) -> bool: + return ResourceState.CONFIRMED in self.state + + @property + def is_paused(self) -> bool: + return ResourceState.PAUSED in self.state + + @property + def is_skipped(self) -> bool: + return ResourceState.SKIPPED in self.state + + @property + def is_cancelled(self) -> bool: + return ResourceState.CANCELLED in self.state + + def update(self, new_data: Optional["Resource"]) -> None: + """Update resource with attributes from another resource.""" + if new_data: + self.__dict__.update(new_data.__dict__) + + def __hash__(self) -> int: + return hash(self.name) + + def __eq__(self, other: object) -> bool: + return isinstance(other, Resource) and self.name == other.name + + def __repr__(self) -> str: + return f"<{self.name}>" + + def __str__(self) -> str: + return f"<{self.name}>" + + +class ResourceSchema(Schema): + """ + Marshmallow schema for validation and + serialization/deserialization of a resource class. + """ + + name = fields.Str(required=True) + type = fields.Str(required=True) + data = fields.Raw(allow_none=True) + state = fields.Enum(IntFlag, by_value=True, required=True) + requires = fields.List(fields.Str(), required=True) + prompts = fields.Mapping(fields.Str(), fields.Str(), allow_none=True) + cascade_state = fields.Bool() + prefer_over_wrapper = fields.Bool() + needs_confirmation = fields.Bool() + order_index = fields.Int() + extras = fields.Dict(fields.Str(), fields.Inferred()) + + @post_load + def instantiate(self, data: Dict[str, Any], **kwargs: Dict[str, Any]): + return RESOURCE_MAP[data["type"]](**data) + + +# Add resource to RESOURCE_MAP, +# should always be done for new Resource classes +RESOURCE_MAP[Resource.__name__] = Resource +# Add schema to RESOURCE_SCHEMAS, +# should also be done for new Resource classes +RESOURCE_SCHEMAS[Resource.__name__] = ResourceSchema + + +@dataclass(eq=False, repr=False) +class ListResource(Resource): + """Resource representing a list of items.""" + + data: List[Any] = data_field(default_factory=list) + + +class ListResourceSchema(ResourceSchema): + data = fields.List(fields.Inferred()) + + +RESOURCE_MAP[ListResource.__name__] = ListResource +RESOURCE_SCHEMAS[ListResource.__name__] = ListResourceSchema + + +@dataclass(eq=False, repr=False) +class DictResource(Resource): + """Resource representing a dictionary of items.""" + + data: Dict[str, Any] = data_field(default_factory=dict) + + +class DictResourceSchema(ResourceSchema): + data = fields.Dict(fields.Str(), fields.Inferred()) + + +RESOURCE_MAP[DictResource.__name__] = DictResource +RESOURCE_SCHEMAS[DictResource.__name__] = DictResourceSchema + +# TODO: ? +# ExactlyOneResource (choose one resource from options) +# SetResource (a set of resources)? +# UserInfoResource (user info, e.g. name, age, home address, etc., can use saved data to autofill) +# ... + + +@dataclass(eq=False, repr=False) +class YesNoResource(Resource): + """Resource representing a yes/no answer.""" + + data: bool = False + + +class YesNoResourceSchema(ResourceSchema): + data = fields.Bool() + + +RESOURCE_MAP[YesNoResource.__name__] = YesNoResource +RESOURCE_SCHEMAS[YesNoResource.__name__] = YesNoResourceSchema + + +@dataclass(eq=False, repr=False) +class DateResource(Resource): + """Resource representing a date.""" + + data: datetime.date = data_field(default_factory=datetime.date.today) + + +class DateResourceSchema(ResourceSchema): + data = fields.Date() + + +RESOURCE_MAP[DateResource.__name__] = DateResource +RESOURCE_SCHEMAS[DateResource.__name__] = DateResourceSchema + + +@dataclass(eq=False, repr=False) +class TimeResource(Resource): + """Resource representing a time (00:00-23:59).""" + + data: datetime.time = data_field(default_factory=datetime.time) + + +class TimeResourceSchema(ResourceSchema): + data = fields.Time() + + +RESOURCE_MAP[TimeResource.__name__] = TimeResource +RESOURCE_SCHEMAS[TimeResource.__name__] = TimeResourceSchema + + +@dataclass(eq=False, repr=False) +class DatetimeResource(Resource): + """Resource for wrapping date and time resources.""" + + ... + + +class DatetimeResourceSchema(ResourceSchema): + data = fields.NaiveDateTime(allow_none=True) + + +RESOURCE_MAP[DatetimeResource.__name__] = DatetimeResource +RESOURCE_SCHEMAS[DatetimeResource.__name__] = DatetimeResourceSchema + + +@dataclass(eq=False, repr=False) +class NumberResource(Resource): + """Resource representing a number.""" + + data: int = 0 + + +class NumberResourceSchema(ResourceSchema): + data = fields.Int() + + +RESOURCE_MAP[NumberResource.__name__] = NumberResource +RESOURCE_SCHEMAS[NumberResource.__name__] = NumberResourceSchema + + +@dataclass(eq=False, repr=False) +class StringResource(Resource): + """Resource representing a string.""" + + data: str = "" + + +class StringResourceSchema(ResourceSchema): + data = fields.Str() + + +RESOURCE_MAP[StringResource.__name__] = StringResource +RESOURCE_SCHEMAS[StringResource.__name__] = StringResourceSchema + +# Wrapper, when multiple resources are required +@dataclass(eq=False, repr=False) +class WrapperResource(Resource): + # Wrappers by default prefer to be the current + # resource rather than a wrapper parent + prefer_over_wrapper: bool = True + + +class WrapperResourceSchema(ResourceSchema): + ... + + +RESOURCE_MAP[WrapperResource.__name__] = WrapperResource +RESOURCE_SCHEMAS[WrapperResource.__name__] = WrapperResourceSchema + + +@dataclass(eq=False, repr=False) +class OrResource(WrapperResource): + ... + + +class OrResourceSchema(ResourceSchema): + ... + + +RESOURCE_MAP[OrResource.__name__] = OrResource +RESOURCE_SCHEMAS[OrResource.__name__] = OrResourceSchema + + +@dataclass(eq=False, repr=False) +class FinalResource(Resource): + """Resource representing the final state of a dialogue.""" + + data: Any = None + + +class FinalResourceSchema(ResourceSchema): + ... + + +RESOURCE_MAP[FinalResource.__name__] = FinalResource +RESOURCE_SCHEMAS[FinalResource.__name__] = FinalResourceSchema diff --git a/queries/extras/sonos.py b/queries/extras/sonos.py new file mode 100644 index 00000000..941a6c77 --- /dev/null +++ b/queries/extras/sonos.py @@ -0,0 +1,491 @@ +""" + + Greynir: Natural language processing for Icelandic + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + Class which encapsulates communication with the Sonos API. + +""" +from typing import Dict, Optional, Union, List, Any + +import logging +import json +from typing_extensions import TypedDict +import flask +import requests +from datetime import datetime, timedelta + +from utility import read_api_key +from queries import query_json_api +from query import Query + + +def post_to_json_api( + url: str, + *, + form_data: Optional[Any] = None, + json_data: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, +) -> Union[None, List[Any], Dict[str, Any]]: + """Send a POST request to the URL, expecting a JSON response which is + parsed and returned as a Python data structure.""" + + # Send request + try: + r = requests.post(url, data=form_data, json=json_data, headers=headers) + except Exception as e: + logging.warning(str(e)) + return None + + # Verify that status is OK + if r.status_code not in range(200, 300): + logging.warning("Received status {0} from API server".format(r.status_code)) + return None + + # Parse json API response + try: + res = json.loads(r.text) + return res + except Exception as e: + logging.warning("Error parsing JSON API response: {0}".format(e)) + return None + + +# Translate various icelandic room names to +# preset room names available in the Sonos app +_GROUPS_DICT = { + "fjölskylduherbergi": "Family Room", + "fjölskyldu herbergi": "Family Room", + "stofa": "Living Room", + "eldhús": "Kitchen", + "bað": "Bathroom", + "klósett": "Bathroom", + "svefnherbergi": "Bedroom", + "svefn herbergi": "Bedroom", + "herbergi": "Bedroom", + "skrifstofa": "Office", + "bílskúr": "Garage", + "skúr": "Garage", + "garður": "Garden", + "gangur": "Hallway", + "borðstofa": "Dining Room", + "gestasvefnherbergi": "Guest Room", + "gesta svefnherbergi": "Guest Room", + "gestaherbergi": "Guest Room", + "gesta herbergi": "Guest Room", + "leikherbergi": "Playroom", + "leik herbergi": "Playroom", + "sundlaug": "Pool", + "laug": "Pool", + "sjónvarpsherbergi": "TV Room", + "sjóvarps herbergi": "TV Room", + "ferðahátalari": "Portable", + "ferða hátalari": "Portable", + "verönd": "Patio", + "pallur": "Patio", + "altan": "Patio", + "sjónvarpsherbergi": "Media Room", + "sjónvarps herbergi": "Media Room", + "hjónaherbergi": "Main Bedroom", + "hjóna herbergi": "Main Bedroom", + "anddyri": "Foyer", + "forstofa": "Foyer", + "inngangur": "Foyer", + "húsbóndaherbergi": "Den", + "húsbónda herbergi": "Den", + "hosiló": "Den", + "bókasafn": "Library", + "bókaherbergi": "Library", + "bóka herbergi": "Library", +} + + +class _Creds(TypedDict): + code: str + timestamp: str + access_token: str + refresh_token: str + + +class _SonosSpeakerData(TypedDict): + credentials: _Creds + + +class SonosDeviceData(TypedDict): + sonos: _SonosSpeakerData + + +_OAUTH_ACCESS_ENDPOINT = "https://api.sonos.com/login/v3/oauth/access" +_API_ENDPOINT = "https://api.ws.sonos.com/control/api/v1" +_HOUSEHOLDS_ENDPOINT = f"{_API_ENDPOINT}/households" +_GROUP_ENDPOINT = f"{_API_ENDPOINT}/groups" +_PLAYER_ENDPOINT = f"{_API_ENDPOINT}/players" +_PLAYBACKSESSIONS_ENDPOINT = f"{_API_ENDPOINT}/playbackSessions" +_VOLUME_INCREMENT = 20 + +# TODO - Decide what should happen if user does not designate a speaker but owns multiple speakers +# TODO - Remove debug print statements +# TODO - Testing and proper error handling +# TODO - Implement a cleaner create_or_join_session function that doesn't rely on recursion +class SonosClient: + _encoded_credentials: str = read_api_key("SonosEncodedCredentials") + + def __init__( + self, + device_data: SonosDeviceData, + client_id: str, + group_name: Optional[str] = None, + radio_name: Optional[str] = None, + ): + self._client_id: str = client_id + self._device_data = device_data + self._group_name: Optional[str] = group_name + self._radio_name: Optional[str] = radio_name + self._code: str = self._device_data["sonos"]["credentials"]["code"] + self._timestamp: Optional[str] = self._device_data["sonos"]["credentials"].get( + "timestamp" + ) + + self._access_token: str + self._refresh_token: str + try: + self._access_token = self._device_data["sonos"]["credentials"][ + "access_token" + ] + self._refresh_token = self._device_data["sonos"]["credentials"][ + "refresh_token" + ] + except (KeyError, TypeError): + self._create_token() + self._check_token_expiration() + self._headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + self._households = self._get_households() + self._household_id = self._households[0]["id"] + self._groups = self._get_groups() + self._players = self._get_players() + self._group_id = self._get_group_id() + self._store_data_and_credentials() + + def _check_token_expiration(self) -> None: + """ + Checks if access token is expired, + and calls a function to refresh it if necessary. + """ + timestamp = datetime.strptime(self._timestamp, "%Y-%m-%d %H:%M:%S.%f") + if (datetime.now() - timestamp) > timedelta(hours=24): + self._update_sonos_token() + + def _update_sonos_token(self) -> None: + """ + Updates the access token. + """ + self._refresh_expired_token() + + sonos_dict: SonosDeviceData = { + "sonos": { + "credentials": { + "code": self._code, + "timestamp": self._timestamp, + "access_token": self._access_token, + "refresh_token": self._refresh_token, + } + } + } + + self._store_data(sonos_dict) + + def _refresh_expired_token(self) -> Union[None, List[Any], Dict[str, Any]]: + """ + Helper function for updating the access token. + """ + r = requests.post( + _OAUTH_ACCESS_ENDPOINT, + params={ + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + }, + headers={"Authorization": f"Basic {self._encoded_credentials}"}, + ) + response = json.loads(r.text) + + self._access_token = response["access_token"] + self._timestamp = str(datetime.now()) + + return response + + def _create_token(self) -> Union[None, List[Any], Dict[str, Any]]: + """ + Creates a token given a code + """ + host = str(flask.request.host) + r = requests.post( + _OAUTH_ACCESS_ENDPOINT, + params={ + "grant_type": "authorization_code", + "code": self._code, + "redirect_uri": f"http://{host}/connect_sonos.api", + }, + headers={"Authorization": f"Basic {self._encoded_credentials}"}, + ) + response = json.loads(r.text) + self._access_token = response.get("access_token") + self._refresh_token = response.get("refresh_token") + self._timestamp = str(datetime.now()) + return response + + def _get_households(self) -> List[Dict[str, str]]: + """ + Returns the list of households of the user + """ + response = query_json_api(_HOUSEHOLDS_ENDPOINT, headers=self._headers) + return response["households"] + + def _get_groups(self) -> Dict[str, str]: + """ + Returns the list of groups of the user + """ + cleaned_groups_dict = {} + for _ in range(len(self._households)): + url = f"{_HOUSEHOLDS_ENDPOINT}/{self._household_id}/groups" + + response = query_json_api(url, headers=self._headers) + cleaned_groups_dict = self._create_groupdict_for_db(response["groups"]) + return cleaned_groups_dict + + def _get_group_id(self) -> str: + """ + Returns the group id for the given query + """ + try: + if self._group_name is not None: + translated_group_name = self._translate_group_name() + group_id = self._groups.get(translated_group_name.casefold()) + if group_id: + return group_id + return list(self._groups.values())[0] + except (KeyError, TypeError): + url = f"{_HOUSEHOLDS_ENDPOINT}/{self._household_id}/groups" + + response = query_json_api(url, headers=self._headers) + return response["groups"][0]["id"] + + def _translate_group_name(self) -> str: + """ + Translates the group name to the correct group name + """ + try: + english_group_name = _GROUPS_DICT[self._group_name] + return english_group_name + except (KeyError, TypeError): + return self._group_name + + def _get_players(self) -> Dict[str, str]: + """ + Returns the list of groups of the user + """ + for _ in range(len(self._households)): + url = f"{_HOUSEHOLDS_ENDPOINT}/{self._household_id}/groups" + + response = query_json_api(url, headers=self._headers) + cleaned_players_dict = self._create_playerdict_for_db(response["players"]) + return cleaned_players_dict + + def _get_player_id(self) -> str: + """ + Returns the player id for the given query + """ + try: + player_id = self._players[0]["id"] + return player_id + except (KeyError, TypeError): + url = f"{_HOUSEHOLDS_ENDPOINT}/{self._household_id}/groups" + + response = query_json_api(url, headers=self._headers) + return response["players"][0]["id"] + + def _create_data_dict(self) -> Dict[str, str]: + data_dict = {"households": self._households} + for i in range(len(self._households)): + groups_dict = self._groups + players_dict = self._players + + data_dict["groups"] = groups_dict + data_dict["players"] = players_dict + return data_dict + + def _create_cred_dict(self) -> Dict[str, str]: + cred_dict = {} + cred_dict.update( + { + "access_token": self._access_token, + "refresh_token": self._refresh_token, + "timestamp": self._timestamp, + } + ) + return cred_dict + + def _store_data_and_credentials(self) -> None: + cred_dict = self._create_cred_dict() + sonos_dict = {} + sonos_dict["sonos"] = {"credentials": cred_dict} + self._store_data(sonos_dict) + + def _store_data(self, data: SonosDeviceData) -> None: + new_dict = {"iot_speakers": data} + Query.store_query_data(self._client_id, "iot", new_dict, update_in_place=True) + + def _create_groupdict_for_db(self, groups: list) -> Dict[str, str]: + groups_dict = {} + for i in range(len(groups)): + groups_dict[groups[i]["name"].casefold()] = groups[i]["id"] + return groups_dict + + def _create_playerdict_for_db(self, players: list) -> Dict[str, str]: + players_dict = {} + for i in range(len(players)): + players_dict[players[i]["name"]] = players[i]["id"] + return players_dict + + def _create_or_join_session(self, recursion=None) -> Optional[str]: + url = f"{_GROUP_ENDPOINT}/{self._group_id}/playbackSession/joinOrCreate" + + payload = json.dumps( + {"appId": "com.mideind.embla", "appContext": "embla123"} + ) # FIXME: Use something else than embla123 + + response = post_to_json_api(url, form_data=payload, headers=self._headers) + if response is None: + self.toggle_pause() + if recursion is None: + response = self._create_or_join_session(recursion=True) + else: + return None + session_id = response + + else: + session_id = response["sessionId"] + return session_id + + def play_radio_stream(self, radio_url: Optional[str]) -> Optional[str]: + session_id = self._create_or_join_session() + if radio_url is None: + try: + radio_url = self._device_data["sonos"]["data"]["last_radio_url"] + except KeyError: + radio_url = "http://netradio.ruv.is/rondo.mp3" + + url = f"{_PLAYBACKSESSIONS_ENDPOINT}/{session_id}/playbackSession/loadStreamUrl" + + payload = json.dumps( + { + "streamUrl": radio_url, + "playOnCompletion": True, + # "stationMetadata": {"name": f"{radio_name}"}, + "itemId": "StreamItemId", + } + ) + + response = post_to_json_api(url, form_data=payload, headers=self._headers) + if response is None: + return "Group not found" + data_dict = {"sonos": {"data": {"last_radio_url": radio_url}}} + self._store_data(data_dict) + + def increase_volume(self) -> None: + url = f"{_GROUP_ENDPOINT}/{self._group_id}/groupVolume/relative" + + payload = json.dumps({"volumeDelta": _VOLUME_INCREMENT}) + post_to_json_api(url, form_data=payload, headers=self._headers) + + def decrease_volume(self) -> None: + url = f"{_GROUP_ENDPOINT}/{self._group_id}/groupVolume/relative" + + payload = json.dumps({"volumeDelta": -_VOLUME_INCREMENT}) + post_to_json_api(url, form_data=payload, headers=self._headers) + + def toggle_play(self) -> Union[None, List[Any], Dict[str, Any]]: + """ + Toggles play/pause of a group + """ + url = f"{_GROUP_ENDPOINT}/{self._group_id}/playback/play" + + response = post_to_json_api(url, headers=self._headers) + return response + + def toggle_pause(self) -> Union[None, List[Any], Dict[str, Any]]: + """ + Toggles play/pause of a group + """ + url = f"{_GROUP_ENDPOINT}/{self._group_id}/playback/pause" + + response = post_to_json_api(url, headers=self._headers) + return response + + def play_audio_clip( + self, audioclip_url: str + ) -> Union[None, List[Any], Dict[str, Any]]: + """ + Plays an audioclip from link to .mp3 file + """ + player_id = self._get_player_id() + url = f"{_PLAYER_ENDPOINT}/{player_id}/audioClip" + + payload = json.dumps( + { + "name": "Embla", + "appId": "com.acme.app", + "streamUrl": f"{audioclip_url}", + "volume": 30, + "priority": "HIGH", + "clipType": "CUSTOM", + } + ) + + response = post_to_json_api(url, form_data=payload, headers=self._headers) + return response + + def play_chime(self) -> Union[None, List[Any], Dict[str, Any]]: + player_id = self._get_player_id() + url = f"{_PLAYER_ENDPOINT}/{player_id}/audioClip" + + payload = json.dumps( + { + "name": "Embla", + "appId": "com.acme.app", + "volume": 30, + "priority": "HIGH", + "clipType": "CHIME", + } + ) + + response = post_to_json_api(url, form_data=payload, headers=self._headers) + return response + + def next_song(self) -> Union[None, List[Any], Dict[str, Any]]: + url = f"{_GROUP_ENDPOINT}/{self._group_id}/playback/skipToNextTrack" + + response = post_to_json_api(url, headers=self._headers) + return response + + def prev_song(self) -> Union[None, List[Any], Dict[str, Any]]: + url = f"{_GROUP_ENDPOINT}/{self._group_id}/playback/skipToPreviousTrack" + + response = post_to_json_api(url, headers=self._headers) + return response diff --git a/queries/extras/spotify.py b/queries/extras/spotify.py new file mode 100644 index 00000000..0ed07f82 --- /dev/null +++ b/queries/extras/spotify.py @@ -0,0 +1,309 @@ +""" + + Greynir: Natural language processing for Icelandic + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + Class which encapsulates communication with the Spotify API. + +""" +from typing import Dict, Optional, Union, List, Any + +import logging +import json +import flask +import requests +from datetime import datetime, timedelta + +from utility import read_api_key +from queries import query_json_api +from query import Query + + +def post_to_json_api( + url: str, + *, + form_data: Optional[Any] = None, + json_data: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, +) -> Union[None, List[Any], Dict[str, Any]]: + """Send a POST request to the URL, expecting a JSON response which is + parsed and returned as a Python data structure.""" + + # Send request + try: + r = requests.post(url, data=form_data, json=json_data, headers=headers) + except Exception as e: + logging.warning(str(e)) + return None + + # Verify that status is OK + if r.status_code not in range(200, 300): + logging.warning("Received status {0} from API server".format(r.status_code)) + return None + + # Parse json API response + try: + res = json.loads(r.text) + return res + except Exception as e: + logging.warning("Error parsing JSON API response: {0}".format(e)) + return None + +def put_to_json_api( + url: str, json_data: Optional[Any] = None, headers: Optional[Dict[str, str]] = None +) -> Union[None, List[Any], Dict[str, Any]]: + """Send a PUT request to the URL, expecting a JSON response which is + parsed and returned as a Python data structure.""" + + # Send request + try: + r = requests.put(url, data=json_data, headers=headers) + except Exception as e: + logging.warning(str(e)) + return None + + # Verify that status is OK + if r.status_code not in range(200, 300): + logging.warning("Received status {0} from API server".format(r.status_code)) + return None + + # Parse json API response + try: + if r.text: + res = json.loads(r.text) + return res + return {} + except Exception as e: + logging.warning("Error parsing JSON API response: {0}".format(e)) + return None + + +# TODO Find a better way to play albums +# TODO - Remove debug print statements +# TODO - Testing and proper error handling +class SpotifyClient: + def __init__( + self, + device_data: Dict[str, str], + client_id: str, + song_name: str = None, + artist_name: str = None, + album_name: str = None, + ): + self._api_url = "https://api.spotify.com/v1" + self._client_id = client_id + self._device_data = device_data + self._encoded_credentials = read_api_key("SpotifyEncodedCredentials") + self._code = self._device_data["credentials"]["code"] + self._song_name = song_name + self._artist_name = artist_name + self._song_name = song_name + self._song_uri = None + self._album_name = album_name + self._song_url = None + self._album_url = None + self._timestamp = self._device_data.get("credentials").get("timestamp") + try: + self._access_token = self._device_data["credentials"]["access_token"] + self._refresh_token = self._device_data["credentials"]["refresh_token"] + except (KeyError, TypeError): + self._create_token() + self._check_token_expiration() + self._store_credentials() + + def _create_token(self) -> Union[None, List[Any], Dict[str, Any]]: + """ + Create a new access token for the Spotify API. + """ + host = flask.request.host + url = f"https://accounts.spotify.com/api/token?grant_type=authorization_code&code={self._code}&redirect_uri=http://{host}/connect_spotify.api" + + payload = {} + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {self._encoded_credentials}", + } + + response = post_to_json_api(url, form_data=payload, headers=headers) + self._access_token = response.get("access_token") + self._refresh_token = response.get("refresh_token") + self._timestamp = str(datetime.now()) + return response + + def _check_token_expiration(self) -> None: + """ + Checks if access token is expired, and calls a function to refresh it if necessary. + """ + try: + timestamp = self._device_data["credentials"]["timestamp"] + except (KeyError, TypeError): + return + timestamp = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f") + if (datetime.now() - timestamp) > timedelta(hours=1): + self._update_spotify_token() + + def _update_spotify_token(self) -> None: + """ + Updates the access token + """ + self._refresh_expired_token() + + def _refresh_expired_token(self) -> None: + """ + Helper function for updating the access token. + """ + + url = f"https://accounts.spotify.com/api/token?grant_type=refresh_token&refresh_token={self._refresh_token}" + + payload = {} + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {self._encoded_credentials}", + } + + response = post_to_json_api(url, form_data=payload, headers=headers) + self._access_token = response.get("access_token") + self._timestamp = str(datetime.now()) + + def _store_credentials(self) -> None: + cred_dict = self._create_cred_dict() + self._store_data(cred_dict) + + def _create_cred_dict(self) -> Dict[str, str]: + cred_dict = {} + cred_dict.update( + { + "access_token": self._access_token, + "timestamp": self._timestamp, + } + ) + return cred_dict + + def _store_data(self, data: Dict[str, str]) -> None: + new_dict = {"iot_streaming": {"spotify": data}} + Query.store_query_data(self._client_id, "iot", new_dict, update_in_place=True) + + def _store_credentials(self) -> None: + cred_dict = self._create_cred_dict() + spotify_dict = {} + spotify_dict["credentials"] = cred_dict + self._store_data(spotify_dict) + + def _create_cred_dict(self) -> Dict[str, str]: + cred_dict = {} + cred_dict.update( + { + "access_token": self._access_token, + "refresh_token": self._refresh_token, + "timestamp": self._timestamp, + } + ) + return cred_dict + + def get_song_by_artist(self) -> Optional[str]: + song_name = self._song_name.replace(" ", "%20") # FIXME: URL encode this + artist_name = self._artist_name.replace(" ", "%20") + url = f"{self._api_url}/search?type=track&q={song_name}+{artist_name}" + + payload = "" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + response = query_json_api(url, headers) + try: + self._song_url = response["tracks"]["items"][0]["external_urls"]["spotify"] + self._song_uri = response["tracks"]["items"][0]["uri"] + except IndexError: + return + + return self._song_url + + def get_album_by_artist(self) -> Optional[str]: + album_name = self._album_name.replace(" ", "%20") + artist_name = self._artist_name.replace(" ", "%20") + url = f"{self._api_url}/search?type=album&q={album_name}+{artist_name}" + + payload = "" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + response = query_json_api(url, headers) + try: + self._album_id = response["albums"]["items"][0]["id"] + self._album_url = response["albums"]["items"][0]["external_urls"]["spotify"] + self._album_uri = response["albums"]["items"][0]["uri"] + except IndexError: + return + + return self._album_url + + def get_first_track_on_album(self) -> Optional[str]: + album_name = self._album_name.replace(" ", "%20") + artist_name = self._artist_name.replace(" ", "%20") + url = f"{self._api_url}/albums/{self._album_id}/tracks" + + payload = "" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + response = query_json_api(url, headers) + try: + self._song_uri = response["items"][0]["uri"] + self._first_album_track_url = response["items"][0]["external_urls"][ + "spotify" + ] + except IndexError: + return + + return self._first_album_track_url + + def play_song_on_device(self) -> Union[None, List[Any], Dict[str, Any]]: + url = f"{self._api_url}/me/player/play" + + payload = json.dumps( + { + "context_uri": self._song_uri, + } + ) + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + + response = put_to_json_api(url, payload, headers) + + return response + + def play_album_on_device(self) -> Union[None, List[Any], Dict[str, Any]]: + url = f"{self._api_url}/me/player/play" + + payload = json.dumps( + { + "context_uri": self._album_uri, + } + ) + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}", + } + + response = put_to_json_api(url, payload, headers) + + return response diff --git a/queries/flights.py b/queries/flights.py index bb6628b9..011d9e38 100755 --- a/queries/flights.py +++ b/queries/flights.py @@ -36,7 +36,7 @@ from queries import Query, QueryStateDict from queries.util import query_json_api, is_plural, read_grammar_file -from tree import Result, Node +from tree import ParamList, Result, Node from settings import changedlocale from speech.trans import gssml @@ -107,24 +107,24 @@ def help_text(lemma: str) -> str: _AIRPORT_TO_IATA_MAP = {val: key for key, val in _IATA_TO_AIRPORT_MAP.items()} -def QFlightsQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QFlightsQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _FLIGHTS_QTYPE -def QFlightsArrivalQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QFlightsArrivalQuery(node: Node, params: ParamList, result: Result) -> None: result["departure"] = False -def QFlightsDepartureQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QFlightsDepartureQuery(node: Node, params: ParamList, result: Result) -> None: result["departure"] = True -def QFlightsArrLoc(node: Node, params: QueryStateDict, result: Result) -> None: +def QFlightsArrLoc(node: Node, params: ParamList, result: Result) -> None: result["to_loc"] = result._nominative -def QFlightsDepLoc(node: Node, params: QueryStateDict, result: Result) -> None: +def QFlightsDepLoc(node: Node, params: ParamList, result: Result) -> None: result["from_loc"] = result._nominative diff --git a/queries/fruitseller.py b/queries/fruitseller.py new file mode 100644 index 00000000..67dd2c50 --- /dev/null +++ b/queries/fruitseller.py @@ -0,0 +1,381 @@ +from typing import Any, List, Optional, cast + +import json +import logging +import datetime + +from query import Query, QueryStateDict +from tree import ParamList, Result, Node, TerminalNode +from reynir import NounPhrase +from queries import ( + gen_answer, + AnswerTuple, + parse_num, + natlang_seq, + read_grammar_file, + sing_or_plur, +) +from queries.extras.dialogue import ( + AnsweringFunctionMap, + DialogueStateManager, +) +from queries.extras.resources import ( + DateResource, + ListResource, + Resource, + ResourceState, + TimeResource, + FinalResource, + WrapperResource, +) + +HANDLE_TREE = True + +DIALOGUE_NAME = "fruitseller" + +# The grammar nonterminals this module wants to handle +QUERY_NONTERMINALS = {"QFruitSeller"} + +# The context-free grammar for the queries recognized by this plug-in module +GRAMMAR = read_grammar_file("fruitseller") + + +def banned_nonterminals(q: Query) -> None: + """ + Returns a set of nonterminals that are not + allowed due to the state of the dialogue + """ + if not q.in_dialogue(DIALOGUE_NAME): + q.ban_nonterminal("QFruitSellerQuery") + return + resource_name: str = q.dsm.get_next_active_resource(DIALOGUE_NAME) + if resource_name == "Fruits": + q.ban_nonterminal("QFruitDateQuery") + + +def _generate_fruit_answer( + resource: ListResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + if result.get("fruitsEmpty"): + return gen_answer(resource.prompts["empty"]) + if result.get("fruitOptions"): + return gen_answer(resource.prompts["options"]) + if resource.is_unfulfilled: + return gen_answer(resource.prompts["initial"]) + if resource.is_partially_fulfilled: + ans: str = "" + if "actually_removed_something" in result: + if not result["actually_removed_something"]: + ans += "Ég fann ekki ávöxtinn sem þú vildir fjarlægja. " + return gen_answer( + ans + + resource.prompts["repeat"].format(list_items=_list_items(resource.data)) + ) + if resource.is_fulfilled: + return gen_answer( + resource.prompts["confirm"].format(list_items=_list_items(resource.data)) + ) + return None + + +def _generate_datetime_answer( + resource: Resource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + ans: Optional[str] = None + date_resource: DateResource = cast(DateResource, dsm.get_resource("Date")) + time_resource: TimeResource = cast(TimeResource, dsm.get_resource("Time")) + + if resource.is_unfulfilled: + ans = resource.prompts["initial"] + elif resource.is_partially_fulfilled: + if date_resource.is_fulfilled: + ans = resource.prompts["date_fulfilled"].format( + date=date_resource.data.strftime("%Y/%m/%d") + ) + elif time_resource.is_fulfilled: + ans = resource.prompts["time_fulfilled"].format( + time=time_resource.data.strftime("%H:%M") + ) + elif resource.is_fulfilled: + ans = resource.prompts["confirm"].format( + date_time=datetime.datetime.combine( + date_resource.data, + time_resource.data, + ).strftime("%Y/%m/%d %H:%M") + ) + if ans: + return gen_answer(ans) + return None + + +def _generate_final_answer( + resource: FinalResource, dsm: DialogueStateManager, result: Result +) -> Optional[AnswerTuple]: + ans: Optional[str] = None + if resource.is_cancelled: + return gen_answer(resource.prompts["cancelled"]) + + dsm.set_resource_state(resource.name, ResourceState.CONFIRMED) + date_resource = dsm.get_resource("Date") + time_resource = dsm.get_resource("Time") + ans = resource.prompts["final"].format( + fruits=_list_items(dsm.get_resource("Fruits").data), + date_time=datetime.datetime.combine( + date_resource.data, + time_resource.data, + ).strftime("%Y/%m/%d %H:%M"), + ) + return gen_answer(ans) + + +def _list_items(items: Any) -> str: + item_list: List[str] = [] + for num, name in items: + # TODO: get general plural form + plural_name: str = NounPhrase(name).dative or name + item_list.append(sing_or_plur(num, name, plural_name)) + return natlang_seq(item_list) + + +def QFruitHotWord(node: Node, params: ParamList, result: Result): + result.qtype = "QFRUITACTIVE" + print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSFDDFS") + Query.get_dsm(result).hotword_activated() + + +def QAddFruitQuery(node: Node, params: ParamList, result: Result): + result.qtype = "QAddFruitQuery" + dsm: DialogueStateManager = Query.get_dsm(result) + resource: ListResource = cast(ListResource, dsm.get_resource("Fruits")) + if resource.data is None: + resource.data = [] + query_fruit_index = 0 + while query_fruit_index < len(result.queryfruits): + (number, name) = result.queryfruits[query_fruit_index] + added = False + for index, (fruit_number, fruit_name) in enumerate(resource.data): + if fruit_name == name: + resource.data[index] = (number + fruit_number, name) + added = True + break + if not added: + resource.data.append((number, name)) + query_fruit_index += 1 + dsm.set_resource_state(resource.name, ResourceState.PARTIALLY_FULFILLED) + + +def QRemoveFruitQuery(node: Node, params: ParamList, result: Result): + result.qtype = "QRemoveFruitQuery" + dsm: DialogueStateManager = Query.get_dsm(result) + resource: ListResource = cast(ListResource, dsm.get_resource("Fruits")) + result.actually_removed_something = False + if resource.data is not None: + for _, fruitname in result.queryfruits: + for number, name in resource.data: + if name == fruitname: + resource.data.remove([number, name]) + result.actually_removed_something = True + break + if len(resource.data) == 0: + dsm.set_resource_state(resource.name, ResourceState.UNFULFILLED) + result.fruitsEmpty = True + else: + dsm.set_resource_state(resource.name, ResourceState.PARTIALLY_FULFILLED) + + +def QFruitCancelOrder(node: Node, params: ParamList, result: Result): + dsm: DialogueStateManager = Query.get_dsm(result) + dsm.set_resource_state("Final", ResourceState.CANCELLED) + dsm.set_answer(gen_answer(dsm.get_resource("Final").prompts["cancelled"])) + dsm.finish_dialogue() + result.qtype = "QFruitCancel" + + +def QFruitOptionsQuery(node: Node, params: ParamList, result: Result): + result.qtype = "QFruitOptionsQuery" + result.answer_key = ("Fruits", "options") + result.fruitOptions = True + + +def QFruitYes(node: Node, params: ParamList, result: Result): + + result.qtype = "QFruitYes" + dsm: DialogueStateManager = Query.get_dsm(result) + resource = dsm.current_resource + if ( + not resource.is_confirmed and resource.name in ("Fruits", "DateTime") + ) and resource.is_fulfilled: + dsm.set_resource_state(resource.name, ResourceState.CONFIRMED) + if isinstance(resource, WrapperResource): + for rname in resource.requires: + dsm.get_resource(rname).state = ResourceState.CONFIRMED + + +def QFruitNo(node: Node, params: ParamList, result: Result): + result.qtype = "QFruitNo" + dsm: DialogueStateManager = Query.get_dsm(result) + resource = dsm.current_resource + if resource.name == "Fruits" and not resource.is_confirmed: + if resource.is_partially_fulfilled: + resource.state = ResourceState.FULFILLED + elif resource.is_fulfilled: + resource.state = ResourceState.PARTIALLY_FULFILLED + + +def QFruitNumOfFruit(node: Node, params: ParamList, result: Result): + if "queryfruits" not in result: + result["queryfruits"] = [] + if "fruitnumber" not in result: + result.queryfruits.append([1, result.fruit]) + else: + result.queryfruits.append([result.fruitnumber, result.fruit]) + + +def QFruitNum(node: Node, params: ParamList, result: Result): + fruitnumber = int(parse_num(node, result._nominative)) + if fruitnumber is not None: + result.fruitnumber = fruitnumber + else: + result.fruitnumber = 1 + + +def QFruit(node: Node, params: ParamList, result: Result): + fruit = result._root + if fruit is not None: + result.fruit = fruit + + +def _add_date( + resource: DateResource, dsm: DialogueStateManager, result: Result +) -> None: + if dsm.get_resource("Fruits").is_confirmed: + resource.data = result["delivery_date"] + resource.state = ResourceState.FULFILLED + time_resource = dsm.get_resource("Time") + datetime_resource = dsm.get_resource("DateTime") + if time_resource.is_fulfilled: + datetime_resource.state = ResourceState.FULFILLED + else: + datetime_resource.state = ResourceState.PARTIALLY_FULFILLED + + +def QFruitDate(node: Node, params: ParamList, result: Result) -> None: + result.qtype = "bull" + datenode = node.first_child(lambda n: True) + assert isinstance(datenode, TerminalNode) + cdate = datenode.contained_date + if cdate: + y, m, d = cdate + now = datetime.datetime.utcnow() + + # This is a date that contains at least month & mday + if d and m: + if not y: + y = now.year + # Bump year if month/day in the past + if m < now.month or (m == now.month and d < now.day): + y += 1 + result["delivery_date"] = datetime.date(day=d, month=m, year=y) + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.current_resource.name == "DateTime": + _add_date(cast(DateResource, dsm.get_resource("Date")), dsm, result) + return + raise ValueError("No date in {0}".format(str(datenode))) + + +def _add_time( + resource: TimeResource, dsm: DialogueStateManager, result: Result +) -> None: + if dsm.get_resource("Fruits").is_confirmed: + resource.data = result["delivery_time"] + resource.state = ResourceState.FULFILLED + date_resource = dsm.get_resource("Date") + datetime_resource = dsm.get_resource("DateTime") + if date_resource.is_fulfilled: + datetime_resource.state = ResourceState.FULFILLED + else: + datetime_resource.state = ResourceState.PARTIALLY_FULFILLED + + +def QFruitTime(node: Node, params: ParamList, result: Result): + result.qtype = "bull" + # Extract time from time terminal nodes + tnode = cast(TerminalNode, node.first_child(lambda n: n.has_t_base("tími"))) + if tnode: + aux_str = tnode.aux.strip("[]") + hour, minute, _ = (int(i) for i in aux_str.split(", ")) + if hour in range(0, 24) and minute in range(0, 60): + result["delivery_time"] = datetime.time(hour, minute) + dsm: DialogueStateManager = Query.get_dsm(result) + if dsm.current_resource.name == "DateTime": + _add_time(cast(TimeResource, dsm.get_resource("Time")), dsm, result) + else: + result["parse_error"] = True + + +def QFruitDateTime(node: Node, params: ParamList, result: Result) -> None: + result.qtype = "bull" + datetimenode = node.first_child(lambda n: True) + assert isinstance(datetimenode, TerminalNode) + now = datetime.datetime.now() + y, m, d, h, min, _ = (i if i != 0 else None for i in json.loads(datetimenode.aux)) + if y is None: + y = now.year + dsm: DialogueStateManager = Query.get_dsm(result) + if d is not None and m is not None: + result["delivery_date"] = datetime.date(y, m, d) + if result["delivery_date"] < now.date(): + result["delivery_date"].year += 1 + if dsm.current_resource.name == "DateTime": + _add_date(cast(DateResource, dsm.get_resource("Date")), dsm, result) + + if h is not None and min is not None: + result["delivery_time"] = datetime.time(h, min) + if dsm.current_resource.name == "DateTime": + _add_time(cast(TimeResource, dsm.get_resource("Time")), dsm, result) + + +def QFruitInfoQuery(node: Node, params: ParamList, result: Result): + result.qtype = "QFruitInfo" + dsm: DialogueStateManager = Query.get_dsm(result) + at = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + if at: + (_, ans, voice) = at + ans = "Ávaxtapöntunin þín gengur bara vel. " + ans + voice = "Ávaxtapöntunin þín gengur bara vel. " + voice + dsm.set_answer((dict(answer=ans), ans, voice)) + + +_ANSWERING_FUNCTIONS: AnsweringFunctionMap = { + "Fruits": _generate_fruit_answer, + "DateTime": _generate_datetime_answer, + "Final": _generate_final_answer, +} + + +def sentence(state: QueryStateDict, result: Result) -> None: + """Called when sentence processing is complete""" + q: Query = state["query"] + dsm: DialogueStateManager = q.dsm + print("Wtf, checking if im in this dialogue") + if dsm.not_in_dialogue(): + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + # Successfully matched a query type + try: + print("Getting answer...") + ans = dsm.get_answer(_ANSWERING_FUNCTIONS, result) + print("GOT IT...") + if not ans: + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + q.set_qtype(result.qtype) + q.set_answer(*ans) + return + except Exception as e: + logging.warning( + "Exception {0} while processing fruit seller query '{1}'".format(e, q.query) + ) + q.set_error("E_EXCEPTION: {0}".format(e)) diff --git a/queries/geography.py b/queries/geography.py index f5c22a5e..e68ce324 100755 --- a/queries/geography.py +++ b/queries/geography.py @@ -47,7 +47,7 @@ location_info, capitalize_placename, ) -from tree import Result, Node +from tree import ParamList, Result, Node _GEO_QTYPE = "Geography" @@ -81,24 +81,24 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("geography") -def QGeoQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _GEO_QTYPE -def QGeoCapitalQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoCapitalQuery(node: Node, params: ParamList, result: Result) -> None: result["geo_qtype"] = "capital" -def QGeoCountryQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoCountryQuery(node: Node, params: ParamList, result: Result) -> None: result["geo_qtype"] = "country" -def QGeoContinentQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoContinentQuery(node: Node, params: ParamList, result: Result) -> None: result["geo_qtype"] = "continent" -def QGeoLocationDescQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoLocationDescQuery(node: Node, params: ParamList, result: Result) -> None: result["geo_qtype"] = "loc_desc" @@ -120,7 +120,7 @@ def _preprocess(name: str) -> str: return fixed -def QGeoSubject(node: Node, params: QueryStateDict, result: Result) -> None: +def QGeoSubject(node: Node, params: ParamList, result: Result) -> None: n = capitalize_placename(_preprocess(result._text)) nom = NounPhrase(n).nominative or n result.subject = nom diff --git a/queries/grammars/fruitseller.grammar b/queries/grammars/fruitseller.grammar new file mode 100644 index 00000000..2f359e25 --- /dev/null +++ b/queries/grammars/fruitseller.grammar @@ -0,0 +1,115 @@ +Query → + QFruitSeller '?'? + +QFruitSeller → + QFruitSellerQuery + | QFruitHotWord + +QFruitSellerQuery → + QFruitQuery '?'? + | QFruitDateQuery '?'? + | QFruitInfoQuery '?'? + +QFruitInfoQuery → + "hver"? "er"? "staðan" "á"? "ávaxtapöntuninni"? + +QFruitHotWord → + "ávöxtur" '?'? + | "postur" '?'? + | "póstur" '?'? + | "ég" "vill" "kaupa"? "ávexti" '?'? + | "ég" "vil" "kaupa"? "ávexti" '?'? + | "ég" "vil" "panta"? "ávexti" '?'? + | "mig" "langar" "að" "kaupa" "ávexti" "hjá"? "þér"? '?'? + | "mig" "langar" "að" "panta" "ávexti" "hjá"? "þér"? '?'? + | "get" "ég" "keypt" "ávexti" "hjá" "þér" '?'? + +QFruitQuery → + QAddFruitQuery + | QRemoveFruitQuery + | QChangeFruitQuery + | QFruitOptionsQuery + | QFruitYes + | QFruitNo + | QFruitCancelOrder + +QAddFruitQuery → + "já"? "má"? "ég"? "fá"? QFruitList + | "já"? "get" "ég" "fengið" QFruitList + | "já"? "gæti" "ég" "fengið" QFruitList + | "já"? "ég" "vil" "fá" QFruitList + | "já"? "ég" "vill" "fá" QFruitList + | "já"? "ég" "vil" "panta" QFruitList + | "já"? "ég" "vill" "panta" QFruitList + | "já"? "ég" "vil" "kaupa" QFruitList + | "já"? "ég" "vill" "kaupa" QFruitList + | "já"? "mig" "langar" "að" "fá" QFruitList + | "já"? "mig" "langar" "að" "kaupa" QFruitList + | "já"? "mig" "langar" "að" "panta" QFruitList + +QRemoveFruitQuery → + "taktu" "út" QFruitList + | "slepptu" QFruitList + | "ég"? "vil"? "sleppa" QFruitList + | "ég" "vill" "sleppa" QFruitList + | "ég" "hætti" "við" QFruitList + | "ég" "vil" "ekki" QFruitList + | "ég" "vill" "ekki" QFruitList + +QChangeFruitQuery → + QChangeStart QFruitList QChangeConnector QFruitList + +QChangeStart → + "breyttu" + | "ég" "vil" "frekar" + | "ég" "vill" "frekar" + | "ég" "vil" "skipta" "út" + | "ég" "vill" "skipta" "út" + | "ég" "vil" "breyta" + | "ég" "vill" "breyta" + +QChangeConnector → + "en" | "í" "staðinn" "fyrir" + +QFruitOptionsQuery → + "hvað" "er" "í" "boði" + | "hverjir" "eru" "valmöguleikarnir" + | "hvaða" "valmöguleikar" "eru" "í" "boði" + | "hvaða" "valmöguleikar" "eru" "til" + | "hvaða" "ávexti" "ertu" "með" + | "hvaða" "ávextir" "eru" "í" "boði" + +QFruitList → QFruitNumOfFruit QFruitNumOfFruit* + +QFruitNumOfFruit → QFruitNum? QFruit "og"? + +QFruitNum → + # to is a declinable number word ('tveir/tvo/tveim/tveggja') + # töl is an undeclinable number word ('sautján') + # tala is a number ('17') + to | töl | tala + +QFruit → 'banani' | 'epli' | 'pera' | 'appelsína' + +QFruitYes → "já" "já"* | "endilega" | "já" "takk" | "játakk" | "já" "þakka" "þér" "fyrir" | "já" "takk" "kærlega" "fyrir"? | "jább" "takk"? + +QFruitNo → "nei" "takk"? | "nei" "nei"* | "neitakk" | "ómögulega" + +QFruitCancelOrder → "ég" "hætti" "við" + | "ég" "vil" "hætta" "við" "pöntunina"? + | "ég" "vill" "hætta" "við" "pöntunina" + +QFruitDateQuery → + QFruitDateTime + | QFruitDate + | QFruitTime + +QFruitDateTime → + tímapunkturafs + +QFruitDate → + dagsafs + | dagsföst + +QFruitTime → + "klukkan"? tími \ No newline at end of file diff --git a/queries/grammars/pizza.grammar b/queries/grammars/pizza.grammar new file mode 100644 index 00000000..fc9e4a67 --- /dev/null +++ b/queries/grammars/pizza.grammar @@ -0,0 +1,401 @@ +# TODO: 2x of a topping. "Tvöfalt", "mikið", "extra" +# TODO: Ban more than two instances of a topping. +# TODO: Fix the toppings being a set. Doesn't handle "Ég vil skinku, ólífur og auka skinku." +# TODO: Add to PizzaRequestBare, start conversation with an order. +# TODO: Add the words for margherita to BinPackage +# TODO: Fix bug where "E_QUERY_NOT_UNDERSTOOD" is stored between trees in a single module. +# TODO: Fix ugly inches hotfix. +# TODO: Fix the requirement of saying the number of pizzas to make a prime specification. + +/þgf = þgf +/ef = ef + +Query → + QPizza '?'? + +QPizza → + QPizzaQuery + | QPizzaHotWord + +QPizzaQuery → + QPizzaDialogue + +# Hotwords are used to initialize the conversation. +QPizzaHotWord → + QPizzaWord/nf # e.g. "Pítsa" + | QPizzaRequestBare # e.g. "Ég vil panta pizzu." + +# Doesn't allow for any order specification, e.g. the number of pizzas, only "Ég vil pizzu." +QPizzaRequestBare → + QPizzaRequestPleasantries? QPizzaWord/þf + +QPizzaDialogue → + QPizzaYes + | QPizzaNo + | QPizzaCancel # Request to cancel the order. + | QPizzaStatus # Request for the status of the order. + | QPizzaQuestion # Question about the features of a particular pizza. + | QPizzaExtrasAnswer # Answer to the question "Do you want anything with your pizzas?" + | QPizzaNumberAndSpecificationAnswer # Answer specifying features of the pizza. + +QPizzaExtrasAnswer → + QPizzaRequestPleasantries? QPizzaExtraWords/þf QPizzaMedPitsunniPhrase? # e.g. "Ég vil brauðstangir með pizzunum." + | QPizzaEgVil "líka" QPizzaExtraWords/þf QPizzaMedPitsunniPhrase? # e.g. "Ég vil líka kanilgott með pizzunum." + | QPizzaExtraWords/nf # e.g. "Kók." + +QPizzaYes → "já" "já"* | "endilega" | "já" "takk" | "játakk" | "já" "þakka" "þér" "fyrir" | "já" "takk" "kærlega" "fyrir"? | "jább" "takk"? + +QPizzaNo → "nei" "takk"? | "nei" "nei"* | "neitakk" | "ómögulega" + +QPizzaCancel → + "ég" "hætti" "við" + | QPizzaEgVil? "hætta" "við" 'pöntun:kvk'_et/þf? + | "hætta" 'pöntun:kvk'_et/þgf? + | "hættu" + +QPizzaStatus → + "staðan" + | "hver" "er" "staðan" "á" 'pöntun:kvk'_et/þgf? + | "hver" "er" "staðan" + | "segðu" "mér" "stöðuna" + | "hvernig" "er" "staðan" + | "hvar" "var" "ég" + | "hvert" "var" "ég" 'kominn' + | "hvert" "var" "ég" 'kominn' "í" 'pöntun:kvk'_et/þgf? + | "hver" "var" "staðan" "á"? 'pöntun:kvk'_et/þgf? + | QPizzaEgVil? "halda" "áfram" "með" 'pöntun:kvk'_et/þf? + +QPizzaQuestion -> + "hvað" "er" "á" QPizzaWord/þgf "númer"? QPizzaNum/nf + | "hvernig" "er" QPizzaWord/þgf "númer"? QPizzaNum/nf + | QPizzaWord/þgf "númer"? QPizzaNum/nf + +QPizzaNumberAndSpecificationAnswer → + QPizzaRequestPleasantries? QPizzaNumberAndSpecificationWrapper/þf + | QPizzaNumberAndSpecificationWrapper/nf + +# Wrapper necessary to account for multiple pizza specifications. +QPizzaNumberAndSpecificationWrapper/fall → + QPizzaNumberAndSpecification/fall QPizzaOgNumberAndSpecification/fall* + +# The number is outside of the specification as it specifies the number of pizzas with the given specifications. +# This clarity makes the handling in the pizza module easier. +QPizzaNumberAndSpecification/fall → + QPizzaNum/fall? QPizzaSpecification/fall + #| QPizzaNum/fall QPizzaWord/fall + +QPizzaSpecification/fall → + QPizzaPrimeSpecification/fall # Specifies a single thing. + | QPizzaCompositeSpecification/fall # Specifies multiple things at once: size, toppings, crust. + +QPizzaPrimeSpecification/fall → + QPizzaMenuOrToppingsSpecification/fall # Specifying which menu item to order or custom toppings. + | QPizzaSizeSpecification/fall # Specifying the size of the pizza. + | QPizzaCrustSpecification/fall # Specifying the crust type on the pizza. + +QPizzaMenuOrToppingsSpecification/fall → + QPizzaMenuSpecification/fall # A specification for a pizza on the menu. + | QPizzaToppingsSpecification/fall # A specification for a custom pizza. + +QPizzaMenuSpecification/fall → + QPizzaMenuWords/fall # e.g. "Margaríta." + +QPizzaToppingsSpecification/fall → + QPizzaToppingsList/fall QPizzaAPitsunaPhrase? # e.g. "Skinku, ólífur og pepperóní á pítsuna." + | QPizzaWord/fall QPizzaMedToppingsPhrase # e.g. "Með ólífum og ananas." + +QPizzaSizeSpecification/fall → + QPizzaSizePhrase/fall # e.g. "Tólf tommu pítsa." + +QPizzaCrustSpecification/fall → + QPizzaCrustPhrase/fall QPizzaAPitsunaPhrase? # e.g. "Klassískan botn á pítsuna." + | QPizzaWord/fall QPizzaMedCrustPhrase # e.g. "Pizza með ítölskum botni." + +QPizzaCompositeSpecification/fall → + QPizzaSizeMenuPhrase/fall QPizzaToppingsCrustPermutation? # e.g. "Ég vil stóra margarítu." + | QPizzaSizeOrMenu/fall QPizzaToppingsCrustPermutation # e.g. "Ég vil tvær margarítur með ítölskum botni." + +# Ways of mentioning the pizza while specifying exactly one feature. +QPizzaSizeOrMenu/fall → + QPizzaSizePhrase/fall # e.g. "Tólf tommu pitsa." + | QPizzaMenuWords/fall # e.g. "Margaríta." + +QPizzaToppingsList/fall → + QPizzaToppingsWord/fall QPizzaOgMedToppingsPhrase/fall* + +QPizzaSize/fall → + QPizzaSizeLarge/fall + | QPizzaSizeMedium/fall + | QPizzaSizeSmall/fall + +QPizzaOgNumberAndSpecification/fall → + "og"? "svo"? "síðan"? "líka"? "einnig"? QPizzaNumberAndSpecification/fall QPizzaIVidbotPhrase? + +QPizzaOgMedToppingsPhrase/fall → + "og"? 'með:fs'? QPizzaToppingsWord/fall +$score(+100) QPizzaOgMedToppingsPhrase/fall + +# It is common to say "miðstærð af pítsu", which is handled separately here. +QPizzaSizePhrase/fall → + QPizzaSize/fall QPizzaWord/fall + | QPizzaMediumWord/fall QPizzaAfPitsuPhrase? + +# This duplicate is a result of difficulties with the composite logic. +QPizzaSizeMenuPhrase/fall → + QPizzaSize/fall QPizzaMenuWords/fall + | QPizzaMediumWord/fall QPizzaAfMenuPhrase + +QPizzaCrustPhrase/fall → + QPizzaCrustType/fall QPizzaCrustWord/fall? + +QPizzaToppingsCrustPermutation → + QPizzaMedToppingsPhrase "og:st"? 'með:fs'? QPizzaCrustPhrase/þgf? + | QPizzaMedCrustPhrase "og:st"? 'með:fs'? QPizzaToppingsList/þgf? + +# This duplicate is a result of difficulties with the composite logic. +QPizzaAfMenuPhrase → + "af" QPizzaMenuWords/þgf + +QPizzaAPitsunaPhrase → + "á" QPizzaWord/þf + +QPizzaMedPitsunniPhrase → + "með" QPizzaWord/þgf + +QPizzaMedToppingsPhrase → + "með" QPizzaToppingsList/þgf + +QPizzaMedCrustPhrase → + "með" QPizzaCrustPhrase/þgf + +QPizzaIVidbotPhrase → + 'í:fs' 'viðbót:kvk'_et/þf + +QPizzaAfPitsuPhrase → + "af" QPizzaWord/þgf + +QPizzaOrMenuWord/fall → + QPizzaWord/fall + | QPizzaMenuWords/fall + +# Toppings that are transcribed in different ways are in separate nonterminals for clarity. +# This also helps standardize the handling of each topping in the module, i.e. not reading "ólífa" and "ólíva" as separate toppings. +QPizzaToppingsWord/fall → + QPizzaMushroom/fall + | QPizzaPepperoni/fall + | 'ananas:kk'/fall + | 'skinka:kvk'/fall + | QPizzaOlive/fall + +QPizzaMenuWords/fall → + 'prinsessa:kvk'/fall + | 'dóttir:kvk'/fall + | QPizzaMargherita/fall + | 'kjöt-veisla:kvk'/fall + | 'hvítlauksbrauð:hk'/fall + | QPizzaTokyo/fall + +QPizzaExtraWords/fall → + QPizzaSidesWords/fall + | QPizzaDrinksWords/fall + | QPizzaDipsWords/fall + +QPizzaSidesWords/fall → + QPizzaLargeBreadsticks/fall + | QPizzaSmallBreadsticks/fall + | 'lítill:lo'_kvk_ft/fall 'brauðstöng:kvk'_ft/fall + | "ostagott" + | "kanilgott" + | "súkkulaðigott" + | 'kartöflubátur:kk'_ft/fall + | 'vængur:kk'_ft/fall + +QPizzaDrinksWords/fall → + QPizzaCoke/fall + | QPizzaCokeZero/fall + | "fanta" + | 'toppur:kk'_et/fall + | 'sítrónu-toppur:kk'_et/fall + | 'appelsínu-svali:kk'_et/fall + | 'epla-svali:kk'_et/fall + | "monster" + +QPizzaDipsWords/fall → + 'hvítlauks-olía:kvk'_et/fall + | 'hvítlauks-sósa:kvk'_et/fall + | 'brauð-stanga-sósa:kvk'_et/fall + | QPizzaBlueCheese/fall + | 'sterkur:lo'_kvk_et/fall 'sósa:kvk'_et/fall + | 'kokteilsósa:kvk'_et/fall + | "súkkulaðiglassúr" + | "glassúr" + +# A large pizza at Domino's is typically thought to be 16", some believe it to be 15". +# The actual size is 14.5". +QPizzaSizeLarge/fall → + 'stór:lo'/fall + | QPizzaSixteenWord QPizzaInchesWord? + | QPizzaFifteenWord QPizzaInchesWord? + | QPizzaFourteenPointFiveWord QPizzaInchesWord? + +QPizzaSizeMedium/fall → + 'millistór:lo'/fall + | 'meðalstór:lo'/fall + | QPizzaTwelveWord QPizzaInchesWord? + +QPizzaMediumWord/fall → + 'mið-stærð:kvk'/fall + +QPizzaSizeSmall/fall → + 'lítill:lo'/fall + | QPizzaNineWord QPizzaInchesWord? + +QPizzaCrustType/fall → + QPizzaItalianWord/fall + | QPizzaClassicWord/fall + +QPizzaRequestPleasantries → + QPizzaEgVil QPizzaKaupaFaraFaPanta? + +QPizzaEgVil → + "ég"? "vil" + | "ég" "vill" + | "mig" "langar" "að" + | "mig" "langar" "í" + | "ég" "ætla" "að" + +QPizzaKaupaFaraFaPanta → + "kaupa" "mér"? + | "fá" "mér"? + | "panta" "mér"? + +QPizzaWord/fall → + 'pizza:kvk'/fall + | 'pitsa:kvk'/fall + | 'pítsa:kvk'/fall + | 'flatbaka:kvk'/fall + +# The size nonterminals assume that the word pizza follows them. +# This creates an issue as that requires the inches word to be in the possessive case. +# That is not the case when answering the question "Hversu stór á pizzan að vera?", answer "sextán tommur." +QPizzaInchesWord -> + 'tomma:kvk'/nf + | 'tomma:kvk'/ef + +QPizzaSixteenWord → + "16" + | "sextán" + +QPizzaFifteenWord → + "15" + | "fimmtán" + +QPizzaFourteenPointFiveWord → + QPizzaFourteenWord "komma" QPizzaFiveWord + +QPizzaFourteenWord → + "14" + | "fjórtán" + +QPizzaFiveWord → + "5" + | "fimm" + +QPizzaTwelveWord → + "12" + | "tólf" + +QPizzaNineWord → + "9" + | "níu" + +QPizzaItalianWord/fall → + 'ítalskur:lo'/fall + +QPizzaClassicWord/fall → + 'klassískur:lo'/fall + +QPizzaCrustWord/fall → + 'botn:kk'/fall + +QPizzaNum/fall → + # to is a declinable number word ('tveir/tvo/tveim/tveggja') + # töl is an undeclinable number word ('sautján') + # tala is a number ('17') + to | töl | tala + +QPizzaPepperoni/fall → + 'pepperóní:hk'/fall + | "pepperoni" + | "pepperóni" + | "pepperoní" + +QPizzaOlive/fall → + 'ólífa:kvk'/fall + | 'ólíva:kvk'/fall + +QPizzaMushroom/fall → + 'sveppur:kk'/fall + | 'Sveppi:kk' + +# "Margaríta" is not recognized by bin, so I hack the grammar here to make it work. +QPizzaMargherita_nf → + "margaríta" + | "margarítur" + | "margarita" + | "margaritur" + +QPizzaMargherita_þf → + "margarítu" + | "margarítur" + | "margaritu" + | "margaritur" + +QPizzaMargherita_þgf → + "margarítu" + | "margarítum" + | "margaritu" + | "margaritum" + +QPizzaMargherita_ef → + "margarítu" + | "margaríta" + | "margarítna" + | "margaritu" + | "margarita" + | "margaritna" + +QPizzaTokyo/fall → + 'Tókýó:kvk'/fall + | "Tókíó" + | "Tokyo" + | "tókýó" + | "tókíó" + | "tokyo" + +QPizzaLargeBreadsticks/fall → + 'stór:lo'_kvk_ft/fall? 'brauð-stöng:kvk'_ft/fall + | 'brauð-stöng:kvk'_ft/fall 'stór:lo'_kvk_ft/fall + +QPizzaSmallBreadsticks/fall → + 'lítill:lo'_kvk_ft/fall 'brauð-stöng:kvk'_ft/fall + | 'brauð-stöng:kvk'_ft/fall 'lítill:lo'_kvk_ft/fall + +QPizzaCoke/fall → + QPizzaCokeWord/fall + +QPizzaCokeZero/fall → + QPizzaCokeWord/fall "zero" + | QPizzaCokeWord/fall "án" 'sykur:kk'_et_ef + +QPizzaBlueCheese/fall → + 'gráðaosta-sósa:kvk'_et/fall + | 'gráðosta-sósa:kvk'_et/fall + | 'gráðaostur:kk'_et/fall + | 'gráðostur:kk'_et/fall + +QPizzaCokeWord/fall → + 'kók:kvk'_et/fall + | "kóka-kóla" + | "coke" + | "coca-cola" \ No newline at end of file diff --git a/queries/grammars/smartlights.grammar b/queries/grammars/smartlights.grammar new file mode 100644 index 00000000..1937f45d --- /dev/null +++ b/queries/grammars/smartlights.grammar @@ -0,0 +1,343 @@ + +Query → QLight + +QLight → QLightQuery '?'? + +QLightQuery → + QLightTurnOnLights + | QLightTurnOffLights + | QLightIncreaseBrightness + | QLightDecreaseBrightness + | QLightChangeColor + | QLightChangeScene + | QLightLetThereBeLight + +QLightLetThereBeLight → "verði" "ljós" +QLightKveiktu → "vinsamlegast"? "kveiktu" | "kveikja" +QLightSlökktu → "slökktu" | "slökkva" +QLightGerðu → "gerðu" | "gera" | "aðgerð" +QLightStilltuSettu → "stilltu" | "settu" | "stilla" | "setja" +QLightBreyttuSkiptu → "breyttu" | "skiptu" | "breyta" | "skipta" +QLightLáttu → "láttu" | "láta" +QLightHækkaðuAuktu → "hækkaðu" | "auktu" | "hækka" | "auka" +QLightLækkaðuMinnkaðu → "lækkaðu" | "minnkaðu" | "lækka" | "minnka" +QLightVeraVerða → "vera" | "verða" + +# Commands for turning on lights +QLightTurnOnLights → + QLightKveiktu QLightLight_þf? QLightHvar # ... ljósið í eldhúsinu + | QLightKveiktu "á" QLightLight_þgf QLightHvar? # ... á lampanum í stofunni + | QLightKveiktu QLightAllLights_þf # ... öll ljósin + | QLightKveiktu "á" QLightAllLights_þgf # ... á öllum ljósum + +# Commands for turning off lights +QLightTurnOffLights → + QLightSlökktu QLightLight_þf? QLightHvar # ... ljósið í eldhúsinu + | QLightSlökktu "á" QLightLight_þgf QLightHvar? # ... á lampanum í stofunni + | QLightSlökktu QLightAllLights_þf # ... öll ljósin + | QLightSlökktu "á" QLightAllLights_þgf # ... á öllum ljósum + +QLightMeiri → "meiri" | "meira" + +# Commands for increasing light brightness +QLightIncreaseBrightness → + QLightHækkaðuAuktu "ljósið" QLightHvar? # TODO + | QLightHækkaðuAuktu QLightBrightnessSubject_þf QLightHvar? + | QLightGerðu QLightMeiri QLightBrightnessWord_þf QLightHvar + | QLightGerðu QLightBrightnessWord_þf QLightMeiri QLightHvar? + | QLightGerðu QLightBrightnessWord_þf QLightHvar QLightMeiri + +QLightMinni → "minni" | "minna" + +# Commands for decreasing light brightness +QLightDecreaseBrightness → + QLightLækkaðuMinnkaðu QLightLight_þf QLightHvar? + | QLightLækkaðuMinnkaðu QLightBrightnessSubject_þf QLightHvar? + | QLightGerðu QLightBrightnessWord_þf QLightMinni QLightHvar? + | QLightGerðu QLightBrightnessWord_þf QLightHvar QLightMinni + | QLightGerðu QLightMinni QLightBrightnessWord_þf QLightHvar + +QLightÁLitinn → "á" "litinn" | "á" "lit" + +# Commands for changing the current color +QLightChangeColor → + QLightGerðu QLightLight_þf QLightHvar? QLightColorName_þf QLightÁLitinn? + | QLightGerðu QLightLight_þf QLightColorName_þf QLightÁLitinn? QLightHvar + | QLightLáttu QLightLight_þf QLightHvar? QLightVeraVerða QLightColorName_þf QLightÁLitinn? + | QLightLáttu QLightLight_þf QLightVeraVerða QLightColorName_þf QLightÁLitinn? QLightHvar + | QLightStilltuSettu "á" QLightNewColor_þf QLightHvar + | QLightBreyttuSkiptu "yfir"? "í" QLightNewColor_þf QLightHvar + | QLightBreyttuSkiptu "litnum" "á" QLightLight_þgf QLightHvar? "í" "litinn"? QLightColorName_þf + | QLightColorName_nf QLightLight_þf QLightHvar + +# Commands for changing the current scene +QLightChangeScene → + QLightKveiktu "á" QLightNewScene_þgf QLightHvar? + | QLightStilltuSettu "á" QLightNewScene_þf QLightHvar? + | QLightBreyttuSkiptu "yfir"? "í" QLightNewScene_þf QLightHvar? + + +# QLightGerðuX → +# QLightSubject_þf QLightHvar? QLightHvernigMake +# | QLightSubject_þf QLightHvernigMake QLightHvar? +# | QLightHvar? QLightSubject_þf QLightHvernigMake +# | QLightHvar? QLightHvernigMake QLightSubject_þf +# | QLightHvernigMake QLightSubject_þf QLightHvar? +# | QLightHvernigMake QLightHvar? QLightSubject_þf + +# QLightSettuX → +# QLightSubject_þf QLightHvar? QLightHvernigSet +# | QLightSubject_þf QLightHvernigSet QLightHvar? +# | QLightHvar? QLightSubject_þf QLightHvernigSet +# | QLightHvar? QLightHvernigSet QLightSubject_þf +# | QLightHvernigSet QLightSubject_þf QLightHvar? +# | QLightHvernigSet QLightHvar? QLightSubject_þf + +# QLightBreyttuX → +# QLightSubjectOne_þgf QLightHvar? QLightHvernigChange +# | QLightSubjectOne_þgf QLightHvernigChange QLightHvar? +# | QLightHvar? QLightSubjectOne_þgf QLightHvernigChange +# | QLightHvar? QLightHvernigChange QLightSubjectOne_þgf +# | QLightHvernigChange QLightSubjectOne_þgf QLightHvar? +# | QLightHvernigChange QLightHvar? QLightSubjectOne_þgf + +# QLightLáttuX → +# QLightSubject_þf QLightHvar? QLightHvernigLet +# | QLightSubject_þf QLightHvernigLet QLightHvar? +# | QLightHvar? QLightSubject_þf QLightHvernigLet +# | QLightHvar? QLightHvernigLet QLightSubject_þf +# | QLightHvernigLet QLightSubject_þf QLightHvar? +# | QLightHvernigLet QLightHvar? QLightSubject_þf + +# QLightSubject/fall → +# QLightSubjectOne/fall +# | QLightSubjectTwo/fall + +# # TODO: Decide whether LightSubject_þgf should be accepted +# QLightSubjectOne/fall → +# QLightLight/fall +# | QLightColorSubject/fall +# | QLightBrightnessSubject/fall +# | QLightSceneWord/fall + +# QLightSubjectTwo/fall → +# QLightGroupName/fall # á bara að styðja "gerðu eldhúsið rautt", "gerðu eldhúsið rómó" "gerðu eldhúsið bjartara", t.d. + +QLightHvar → + QLightLocationPreposition QLightGroupName_þgf + | QLightEverywhere + +# QLightHvernigMake → +# QLightAnnaðAndlag # gerðu litinn rauðan í eldhúsinu EÐA gerðu birtuna meiri í eldhúsinu +# | QLightAðHverju # gerðu litinn að rauðum í eldhúsinu +# | QLightÞannigAð + +# QLightHvernigSet → +# QLightÁHvað +# | QLightÞannigAð + +# QLightHvernigChange → +# QLightÍHvað +# | QLightÞannigAð + +# QLightHvernigLet → +# QLightVerða QLightSomethingOrSomehow +# | QLightVera QLightSomehow + +# QLightÞannigAð → +# "þannig" "að"? pfn_nf QLightBeOrBecomeSubjunctive QLightAnnaðAndlag + +# QLightBeOrBecomeSubjunctive → +# "verði" | "sé" + +# QLightColorSubject/fall → +# QLightColorWord/fall QLightLight_ef? +# | QLightColorWord/fall "á" QLightLight_þgf + +QLightBrightnessSubject/fall → + QLightBrightnessWord/fall QLightLight_ef? + +QLightLocationPreposition → + QLightLocationPrepositionFirstPart? QLightLocationPrepositionSecondPart + +# The latter proverbs are grammatically incorrect, but common errors, both in speech and transcription. +# The list provided is taken from StefnuAtv in Greynir.grammar. That includes "aftur:ao", which is not applicable here. +QLightLocationPrepositionFirstPart → + StaðarAtv + | "fram:ao" + | "inn:ao" + | "niður:ao" + | "upp:ao" + | "út:ao" + +QLightLocationPrepositionSecondPart → "á" | "í" + +QLightLight/fall → + 'ljós:no'/fall + | QLightLightName/fall + +# QLightLjósTegund/fall → +# | 'lampi:no'/fall +# | 'útiljós'/fall +# | 'leslampi'/fall +# | 'borðlampi'/fall +# | 'gólflampi'/fall +# | 'lýsing'/fall +# | 'birta'/fall +# | 'Birta'/fall + +QLightGroupName/fall → no/fall + # | sérnafn/fall + # | QLightBanwords/fall + +# Note: don't use this for 'öll ljósin í svefnherberginu' +# as this is for ALL lights not all lights in an area +QLightAllLights/fall → + 'allur:fn'_ft_hk/fall 'ljós:hk'/fall + +QLightEverywhere → + "alls_staðar" + | "alstaðar" + | "allstaðar" + | "allsstaðar" + | "alsstaðar" + | "öllu" "húsinu" + +QLightLightName/fall → no/fall + # | sérnafn/fall + # | QLightBanwords/fall + +QLightColorName/fall → {color_names} + +QLightSceneName/fall → no/fall + # | sérnafn/fall + # | QLightBanwords/fall + +# QLightAnnaðAndlag → +# QLightNewSetting/nf +# | QLightSpyrjaHuldu/nf + +# QLightAðHverju → +# "að" QLightNewSetting_þgf + +# QLightÁHvað → +# "á" QLightNewSetting_þf + +# QLightÍHvað → +# "í" QLightNewSetting_þf + +# QLightÁHverju → +# "á" QLightLight_þgf + # | "á" QLightNewSetting_þgf + +# QLightSomethingOrSomehow → +# QLightAnnaðAndlag +# | QLightAðHverju + +# QLightSomehow → +# QLightAnnaðAndlag +# | QLightÞannigAð + +QLightColorWord/fall → + 'litur'/fall + | 'litblær'/fall + | 'blær'/fall + +QLightBrightnessWords/fall → + 'bjartur:lo'/fall + | QLightBrightnessWord/fall + +QLightBrightnessWord/fall → + 'birta:kvk'/fall + | 'Birta'/fall + | 'birtustig'/fall + +QLightSceneWord/fall → + 'sena:kvk'/fall + | 'stemning'/fall + | 'stemming'/fall + | 'stemmning'/fall + | 'sina:kvk'/fall + | "siðunni" + | 'Sena'/fall + | "Sena" + | "senuni" + +# Need to ask Hulda how this works. +# QLightSpyrjaHuldu/fall → +# # QLightHuldaColor/fall +# QLightHuldaBrightness/fall +# # | QLightHuldaScene/fall +# | QLightHuldaTemp/fall + +# Do I need a "new light state" non-terminal? +# QLightNewSetting/fall → +# QLightNewColor/fall +# | QLightNewBrightness/fall +# | QLightNewScene/fall + +# # TODO: Missing "meira dimmt" +# QLightHuldaBrightness/fall → +# QLightMoreBrighterOrHigher/fall QLightBrightnessWords/fall? +# | QLightLessDarkerOrLower/fall QLightBrightnessWords/fall? + +# QLightHuldaTemp/fall → +# QLightWarmer/fall +# | QLightCooler/fall + +# # TODO: Unsure about whether to include /fall after QLightColorName +QLightNewColor/fall → + QLightColorWord/fall QLightColorName/fall + | QLightColorName/fall QLightColorWord/fall? + +# QLightNewBrightness/fall → +# 'sá'/fall? QLightBrightestOrDarkest/fall +# | QLightBrightestOrDarkest/fall QLightBrightnessOrSettingWord/fall + +QLightNewScene/fall → + QLightSceneWord/fall QLightSceneName_nf + | QLightSceneWord/fall QLightSceneName/fall + # | QLightSceneName QLightSceneWord/fall? + +# QLightMoreBrighterOrHigher/fall → +# 'mikill:lo'_mst/fall +# | 'bjartur:lo'_mst/fall +# | 'ljós:lo'_mst/fall +# | 'hár:lo'_mst/fall + +# QLightLessDarkerOrLower/fall → +# 'lítill:lo'_mst/fall +# | 'dökkur:lo'_mst/fall +# | 'dimmur:lo'_mst/fall +# | 'lágur:lo'_mst/fall + +# QLightWarmer/fall → +# 'hlýr:lo'_mst/fall +# | 'heitur:lo'_mst/fall +# | "hlýri" + +# QLightCooler/fall → +# 'kaldur:lo'_mst/fall + +# QLightBrightestOrDarkest/fall → +# QLightBrightest/fall +# | QLightDarkest/fall + +# QLightBrightest/fall → +# 'bjartur:lo'_evb +# | 'bjartur:lo'_esb +# | 'ljós:lo'_evb +# | 'ljós:lo'_esb + +# QLightDarkest/fall → +# 'dimmur:lo'_evb +# | 'dimmur:lo'_esb +# | 'dökkur:lo'_evb +# | 'dökkur:lo'_esb + +# QLightBrightnessOrSettingWord/fall → +# QLightBrightnessWord/fall +# | QLightStilling/fall + +# QLightStilling/fall → +# 'stilling'/fall diff --git a/queries/grammars/smartspeakers.grammar b/queries/grammars/smartspeakers.grammar new file mode 100644 index 00000000..6aea2152 --- /dev/null +++ b/queries/grammars/smartspeakers.grammar @@ -0,0 +1,469 @@ +# Context-free grammar for smartspeakers module + +Query → QSpeaker '?'? +QSpeaker → QSpeakerQuery +QSpeakerQuery → + QSpeakerResume + | QSpeakerPause + | QSpeakerNextSong + | QSpeakerLastSong + | QSpeakerPlayRadio + | QSpeakerIncreaseVolume + | QSpeakerDecreaseVolume + # | QSpeakerPlayPlaylist + # QSpeakerGera/nhbh QSpeakerMakeRest + # | QSpeakerSetja/nhbh QSpeakerSetRest + # | QSpeakerLáta/nhbh QSpeakerLetRest + # | QSpeakerTurnOnOrOffVerb QSpeakerTurnOrOffOnRest + # | QSpeakerPlayOrPauseVerb QSpeakerPlayOrPauseRest + # | QSpeakerIncreaseOrDecreaseVerb QSpeakerIncreaseOrDecreaseRest + # | QSpeakerSkippa/nhbh + # | QSpeakerNewSetting/fall + +# Common inflections of verbs at start of commands: +# - Infinitive, active voice (e.g. "gera") +# - Imperative (e.g. "gerðu") +/nhbh = gmnh bh + +QSpeakerGera/nhbh → 'gera:so'/nhbh +QSpeakerSetja/nhbh → 'setja:so'/nhbh | 'stilla:so'/nhbh +QSpeakerBreyta/nhbh → 'breyta:so'/nhbh +QSpeakerLáta/nhbh → 'láta:so'/nhbh +QSpeakerKveikja/nhbh → 'kveikja:so'/nhbh +QSpeakerSlökkva/nhbh → 'slökkva:so'/nhbh +QSpeakerSpila/nhbh → 'spila:so'/nhbh +QSpeakerHækka/nhbh → 'hækka:so'/nhbh | 'auka:so'/nhbh +QSpeakerLækka/nhbh → 'lækka:so'/nhbh | 'minnka:so'/nhbh +QSpeakerPása/nhbh → + 'stöðva:so'/nhbh + | 'stoppa:so'/nhbh + | 'stofna:so'/nhbh # TODO: Fix in beautified query + # "pásaðu" is not recognized by BÍN, but is common in casual speech + | "pásaðu" + | "pása" + | 'gera:so'/nhbh "hlé" "á" +QSpeakerSkippa/nhbh → 'skippa:so'/nhbh | 'skipa:so'/nhbh +QSpeakerSkipta/nhbh → 'skipta:so'/nhbh | "skipt" +QSpeakerVeraVerða → "vera:so" | "verða:so" + +QSpeakerResume → + # "Kveiktu aftur á tónlist í stofunni" + QSpeakerKveikja/nhbh "aftur"? "á"? QSpeakerTónlist_þf QSpeakerÍHátalara? QSpeakerHvar? + # "Spilaðu aftur tónlist í hátölurunum í stofunni" + | QSpeakerSpila/nhbh "aftur"? "áfram"? QSpeakerTónlist_þf QSpeakerÍHátalara? QSpeakerHvar? + +QSpeakerPause → + # "Slökktu á tónlistinni í stofunni" + QSpeakerSlökkva/nhbh "á" QSpeakerTónlistEðaÚtvarp_þgf QSpeakerÍHátalara? QSpeakerHvar? + # "Slökktu á hátölurunum" + | QSpeakerSlökkva/nhbh QSpeakerÁEðaÍ QSpeakerHátalariEðaÚtvarp_þgf QSpeakerHvar? + # "Pásaðu tónlistina í eldhúsinu" + | QSpeakerPása/nhbh QSpeakerTónlistEðaÚtvarp_þf QSpeakerÍHátalara? QSpeakerHvar? + # "Settu útvarpið á pásu" + | QSpeakerSetja/nhbh QSpeakerTónlistEðaÚtvarp_þf "á" "pásu" QSpeakerÍHátalara? QSpeakerHvar? + +QSpeakerNæstaLag → "næsta" "lag:hk" +QSpeakerYfirÍEðaYfirÁ → "yfir" QSpeakerÁEðaÍ +QSpeakerNextSong → + QSpeakerSkippa/nhbh + | QSpeakerNæstaLag + | "ég" "vil" QSpeakerNæstaLag + | QSpeakerSetja/nhbh "á" QSpeakerNæstaLag + | QSpeakerSkipta/nhbh QSpeakerYfirÍEðaYfirÁ QSpeakerNæstaLag + | QSpeakerSkippa/nhbh QSpeakerYfirÍEðaYfirÁ QSpeakerNæstaLag + +QSpeakerLastSong → "" + +QSpeakerPlayRadio → + QSpeakerPlayRadioWithStation + | QSpeakerPlayRadioNoStation + +QSpeakerPlayRadioWithStation → + # "Settu hátalarana yfir á útvarpsstöðina X í eldhúsinu" + # ("á" is only optional because sometimes the TTS doesn't hear it) + QSpeakerSetja/nhbh QSpeakerHátalari_þf "yfir"? "á"? QSpeakerÚtvarpsstöðX_þf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerKveikja/nhbh "á" QSpeakerÚtvarpsstöðX_þgf + +QSpeakerPlayRadioNoStation → + # "Settu hátalarana yfir á útvarpið í stofunni" + QSpeakerSetja/nhbh QSpeakerHátalari_þf? "yfir"? "á"? QSpeakerÚtvarp_þf QSpeakerÍHátalara? QSpeakerHvar? + # "Spilaðu útvarpið í hátölurunum í stofunni" + | QSpeakerSpila/nhbh "aftur"? "áfram"? QSpeakerÚtvarp_þf QSpeakerÍHátalara? QSpeakerHvar? + # Kveiktu á útvarpinu + | QSpeakerKveikja/nhbh "á" QSpeakerÚtvarp_þgf + +QSpeakerIncreaseVolume → + QSpeakerGera/nhbh QSpeakerMusicOrSoundPhrase_þf QSpeakerMeiri_nf QSpeakerHvar + | QSpeakerGera/nhbh QSpeakerMusicOrSoundPhrase_þf QSpeakerHvar? QSpeakerMeiri_nf + | QSpeakerGera/nhbh QSpeakerTónlist_þf QSpeakerMeiri_nf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerGera/nhbh QSpeakerHljóðEðaLæti_þf QSpeakerMeiri_nf QSpeakerÍHátalaraEðaÚtvarpi? QSpeakerHvar? + +QSpeakerDecreaseVolume → + QSpeakerGera/nhbh QSpeakerMusicOrSoundPhrase_þf QSpeakerMinni_nf QSpeakerHvar + | QSpeakerGera/nhbh QSpeakerMusicOrSoundPhrase_þf QSpeakerHvar? QSpeakerMinni_nf + | QSpeakerGera/nhbh QSpeakerTónlist_þf QSpeakerMinni_nf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerGera/nhbh QSpeakerHljóðEðaLæti_þf QSpeakerMinni_nf QSpeakerÍHátalaraEðaÚtvarpi? QSpeakerHvar? + +QSpeakerMakeRest → + QSpeakerMusicOrSoundPhrase_þf QSpeakerComparative_nf QSpeakerHvar + | QSpeakerMusicOrSoundPhrase_þf QSpeakerHvar? QSpeakerComparative_nf + | QSpeakerTónlist_þf QSpeakerComparative_nf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerHljóðEðaLæti_þf QSpeakerComparative_nf QSpeakerÍHátalaraEðaÚtvarpi? QSpeakerHvar? + +QSpeakerSetRest → + QSpeakerHátalariEðaÚtvarp_þf? QSpeakerÁHvað QSpeakerHvar + | QSpeakerHátalariEðaÚtvarp_þf? QSpeakerHvar? QSpeakerÁHvað + +QSpeakerLetRest → + QSpeakerVeraVerða QSpeakerTónlist_nf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerMusicOrSoundPhrase_þf QSpeakerHvar QSpeakerVeraVerða QSpeakerComparative_nf + | QSpeakerMusicOrSoundPhrase_þf QSpeakerVeraVerða QSpeakerComparative_nf QSpeakerHvar? + | QSpeakerTónlist_þf QSpeakerVeraVerða QSpeakerComparative_nf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerHljóðEðaLæti_þf QSpeakerVeraVerða QSpeakerComparative_nf QSpeakerÍHátalaraEðaÚtvarpi? QSpeakerHvar? + +# QSpeakerTurnOrOffOnRest → QSpeakerÁX QSpeakerÍHátalara? QSpeakerHvar? + +QSpeakerPlayOrPauseRest → + QSpeakerTónlist_þf QSpeakerÍHátalara? QSpeakerHvar? + | "útvarpið" QSpeakerÍHátalara? QSpeakerHvar? + | "afspilun" QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerÚtvarpsstöðX_þf QSpeakerÍHátalara? QSpeakerHvar? + | QSpeakerNextOrPrev_þf QSpeakerÍHátalara? QSpeakerHvar? + +QSpeakerIncreaseOrDecreaseRest → + QSpeakerMusicOrSoundPhrase_þf QSpeakerHvar? + | "í" QSpeakerTónlistEðaHátalariEðaÚtvarp_þgf QSpeakerHvar? + | "í" QSpeakerRadioStation QSpeakerHvar? + +QSpeakerComparative/fall → + QSpeakerMeiri/fall + | QSpeakerMinni/fall + +# Sometimes "á" isn't heard by the TTS +QSpeakerÁHvað → + "yfir"? "á"? QSpeakerTónlistEðaÚtvarp_þf + | "yfir"? "á"? QSpeakerNewSetting_þf + +QSpeakerNewSetting/fall → + QSpeakerÚtvarpsstöðX/fall + | QSpeakerNewPlayOrPause/fall + | QSpeakerNextOrPrev/fall + +QSpeakerÚtvarpsstöðX/fall → + QSpeakerÚtvarpsstöð/fall? QSpeakerRadioStation + +QSpeakerNewPlayOrPause/fall → + QSpeakerNewPlay/fall + | QSpeakerNewPause/fall + +QSpeakerNewPlay/fall → + "play" + | "plei" + | "pley" + +QSpeakerNewPause/fall → + 'pása'/fall + | 'stopp'/fall + +QSpeakerNextOrPrev/fall → + QSpeakerNextSong/fall + | QSpeakerPrevSong/fall + +QSpeakerNextSong/fall → + 'næstur:lo'_kvk/fall 'lag:hk'_et/fall + | 'lag:hk'_et_gr/fall "á" "eftir" "þessu"? + +QSpeakerPrevSong/fall → + 'seinastur:lo'_kvk/fall 'lag:hk'_et/fall + | 'síðastur:lo'_kvk/fall 'lag:hk'_et/fall + | 'fyrri:lo'_kvk/fall 'lag:hk'_et/fall + | 'lag:hk'_et_gr/fall "á" "undan" "þessu"? + +QSpeakerMusicOrSoundPhrase/fall → + QSpeakerMusicPhrase/fall + | QSpeakerSoundPhrase/fall + +QSpeakerMusicPhrase/fall → + QSpeakerTónlist/fall QSpeakerHátalariEðaÚtvarp_ef? + | QSpeakerTónlist/fall "í" QSpeakerHátalariEðaÚtvarp_þgf + +QSpeakerSoundPhrase/fall → + QSpeakerHljóðEðaLæti/fall? QSpeakerTónlistEðaHátalariEðaÚtvarp_ef? + | QSpeakerHljóðEðaLæti/fall "í" QSpeakerTónlistEðaHátalariEðaÚtvarp_þgf? + +QSpeakerHljóðEðaLæti/fall → + QSpeakerHljóð/fall + | QSpeakerLæti/fall + +QSpeakerTónlistEðaHátalariEðaÚtvarp/fall → + QSpeakerTónlist/fall + | QSpeakerHátalariEðaÚtvarp/fall + +QSpeakerÚtvarpsstöð/fall → + 'útvarpsstöð'_et/fall + | 'útvarp:hk'? 'stöð:kvk'_et/fall + +QSpeakerTónlist/fall → 'tónlist:kvk'_et/fall | 'tónlistarmaður:kk' # TODO: Fix in beautified query + +QSpeakerHljóð/fall → + 'hljóð'_et/fall + | 'hljóðstyrkur'_et/fall + | 'ljóð'_et/fall + +QSpeakerLæti/fall → + 'læti'_ft/fall + | 'hávaði'_et/fall + +QSpeakerHátalari/fall → 'hátalari'/fall +QSpeakerÚtvarp/fall → 'útvarp'/fall + +QSpeakerHátalariEðaÚtvarp/fall → + QSpeakerHátalari/fall + | QSpeakerÚtvarp/fall + +QSpeakerÍHátalaraEðaÚtvarpi → "í" QSpeakerHátalariEðaÚtvarp_þgf +QSpeakerÍHátalara → "í" QSpeakerHátalari_þgf +# TODO: ^ Score needed for these NT's? + +QSpeakerTónlistEðaÚtvarp/fall → + QSpeakerTónlist/fall + | QSpeakerÚtvarp/fall + +QSpeakerMeiri/fall → + 'mikill:lo'_mst/fall + | 'hár:lo'_mst/fall + +QSpeakerMinni/fall → + 'lítill:lo'_mst/fall + | 'lágur:lo'_mst/fall + +QSpeakerStaðarAo → + "fram" + | "frammi" + | "inn" + | "inni" + | "niðri" + | "niður" + | "upp" + | "uppi" + | "út" + | "úti" + +QSpeakerÁEðaÍ → "á" | "í" + +QSpeakerRoom/fall → no/fall + +QSpeakerAlltHús/fall → 'allur:fn'_et_hk/fall? 'hús:hk'_et/fall +QSpeakerEverywhere → + "alls_staðar" + | "alstaðar" + | "allstaðar" + | "allsstaðar" + | "alsstaðar" + | "á" "öllum" 'staður:kk'_ft_þgf + | "á" "öllum" 'staðsetning:kvk'_ft_þgf + | "í" "öllum" 'herbergi:hk'_ft_þgf + | "út" "um" QSpeakerAlltHús_nf + | "í" QSpeakerAlltHús_þgf + +QSpeakerHvar → + # "inni í eldhúsi", "úti á palli", "frammi í stofu", ... + QSpeakerStaðarAo? QSpeakerÁEðaÍ QSpeakerRoom_þgf + # "alls staðar", "í öllu húsinu", "í öllum herbergjum", ... + | QSpeakerEverywhere + +# List of the supported radio stations +QSpeakerRadioStation → + QSpeakerApparatið + | QSpeakerBylgjan + | QSpeakerFlashback + | QSpeakerFM957 + | QSpeakerFM_Extra + | QSpeakerGullbylgjan + | QSpeakerK100 + | QSpeakerKissFM + | QSpeakerLéttbylgjan + | QSpeakerRetro + | QSpeakerRondó + | QSpeakerRás_1 + | QSpeakerRás_2 + | QSpeakerX977 + | QSpeakerÍslenska_Bylgjan + | QSpeakerÚtvarp_101 + | QSpeakerÚtvarp_Suðurland + | QSpeakerÚtvarp_Saga + | QSpeaker70s_Flashback + | QSpeaker80s_Flashback + | QSpeaker80s_Bylgjan + | QSpeaker90s_Flashback + +QSpeakerBylgjan → + "Bylgjan" + | "Bylgjuna" + | "Bylgjunni" + | "Bylgjunnar" + | "bylgjan" + | "bylgjuna" + | "bylgjunni" + | "bylgjunnar" + +QSpeakerÚtvarp_Saga → + "Útvarp" "Saga" + | "Útvarp" "Sögu" + | "Útvarpi" "Sögu" + | "Útvarp" "Sögu" + | "Útvarps" "Sögu" + | "útvarp" "saga" + | "útvarp" "sögu" + | "útvarpi" "sögu" + | "útvarp" "sögu" + | "útvarps" "sögu" + | "útvarpssaga" + | "útvarpssögu" + +QSpeakerGullbylgjan → + "Gullbylgjan" + | "Gullbylgjuna" + | "Gullbylgjunni" + | "Gullbylgjunnar" + | "gullbylgjan" + | "gullbylgjuna" + | "gullbylgjunni" + | "gullbylgjunnar" + | "gull" "bylgjan" + | "gull" "bylgjuna" + | "gull" "bylgjunni" + | "gull" "bylgjunnar" + +QSpeakerLéttbylgjan → + "Léttbylgjan" + | "Léttbylgjuna" + | "Léttbylgjunni" + | "Léttbylgjunnar" + | "léttbylgjan" + | "léttbylgjuna" + | "léttbylgjunni" + | "léttbylgjunnar" + | "létt" "bylgjan" + | "létt" "bylgjuna" + | "létt" "bylgjunni" + | "létt" "bylgjunnar" + +QSpeakerX977 → + "X-ið" "977"? + | "X-inu" "977"? + | "X-ins" "977"? + | "x-ið" "977"? + | "x-inu" "977"? + | "x-ins" "977"? + | "x" "977" + | "x977" + | "x" "níu" "sjö" "sjö" + | "x-977" + +QSpeakerRás_1 → + "rás" "1" + | "rás" "eitt" + +QSpeakerRás_2 → + "rás" "2" + | "rás" "tvö" + +QSpeakerRondó → + "rondo" "fm"? + | "rondó" "fm"? + | "róndó" "fm"? + | "London" + +QSpeakerFM957 → + "fm" "957" + | "fm957" + +QSpeakerK100 → + "k" "100" + | "k" "hundrað" + | "k100" + | "k-hundrað" + | "k-100" + | "kk" "hundrað" + | "kk" "100" + +QSpeakerRetro → + "retro" "fm"? + | "retró" "fm"? + +QSpeakerKissFM → + "kiss" "fm"? + +QSpeakerFlashback → + "flassbakk" "fm"? + | "flass" "bakk" "fm"? + +QSpeakerÚtvarp_101 → + "útvarp"? "101" + | "útvarp"? "hundrað" "og" "einn" + | "útvarp"? "hundrað" "og" "eitt" + | "útvarp"? "hundrað" "einn" + | "útvarp"? "hundrað" "1" + +QSpeaker80s_Bylgjan → + QSpeaker80s "Bylgjan" + | QSpeaker80s "Bylgjuna" + | QSpeaker80s "Bylgjunni" + | QSpeaker80s "Bylgjunnar" + | QSpeaker80s "bylgjan" + | QSpeaker80s "bylgjuna" + | QSpeaker80s "bylgjunni" + | QSpeaker80s "bylgjunnar" + +QSpeakerÍslenska_Bylgjan → + "íslenska" "Bylgjan" + | "íslensku" "Bylgjuna" + | "íslensku" "Bylgjunni" + | "íslensku" "Bylgjunnar" + | "íslenska" "bylgjan" + | "íslensku" "bylgjuna" + | "íslensku" "bylgjunni" + | "íslensku" "bylgjunnar" + +QSpeakerApparatið → "apparatið" + +QSpeakerFM_Extra → "fm" "extra" + +QSpeakerÚtvarp_Suðurland → + "útvarp"? "suðurland" + | "útvarps"? "suðurlands" + +QSpeaker70s_Flashback → QSpeaker70s QSpeakerFlashbackWord +QSpeaker80s_Flashback → QSpeaker80s QSpeakerFlashbackWord +QSpeaker90s_Flashback → QSpeaker90s QSpeakerFlashbackWord + +QSpeakerFlashbackWord → + "flassbakk" + | "flass" "bakk" + | "bakk" + | "flaska" + | "flashback" + | "flash" "back" + +QSpeaker70s → + "seventís" + | "seventies" + | "70" + | "70s" + +QSpeaker80s → + "eitís" + | "Eydís" + | "eydís" + | "Eidís" + | "eidís" + | "eighties" + | "80" + | "80s" + +QSpeaker90s → + "næntís" + | "nineties" + | "90" + | "90s" diff --git a/queries/grammars/theater.grammar b/queries/grammars/theater.grammar new file mode 100644 index 00000000..241bbc4a --- /dev/null +++ b/queries/grammars/theater.grammar @@ -0,0 +1,222 @@ +Query → + QTheater '?'? + +QTheater → + QTheaterQuery + | QTheaterHotWord + +QTheaterQuery → + QTheaterDialogue + +QTheaterHotWord → + QTheaterNames + | QTheaterEgVil? QTheaterKaupaFaraFaPanta "leikhúsmiða" + | QTheaterEgVil? QTheaterKaupaFaraFaPanta "miða" "í" QTheaterNames + | QTheaterEgVil? QTheaterKaupaFaraFaPanta "miða" "á" QTheaterNames "sýningu" + | QTheaterEgVil? QTheaterKaupaFaraFaPanta QTheaterNames + | QTheaterEgVil? QTheaterKaupaFaraFaPanta "leikhússýningu" + +QTheaterNames → + 'leikhús' + | 'þjóðleikhús' + | 'Þjóðleikhús' + | 'Borgarleikhús' + | 'borgarleikhús' + + +QTheaterKaupaFaraFaPanta → + "kaupa" "mér"? + | "fara" "á" + | "fara" "í" + | "fá" + | "panta" + +QTheaterDialogue → + QTheaterShowQuery + | QTheaterShowDateQuery + | QTheaterMoreDates + | QTheaterPreviousDates + | QTheaterShowSeatCountQuery + | QTheaterShowLocationQuery + | QTheaterShowPrice + | QTheaterShowLength + | QTheaterOptions + | QTheaterYes + | QTheaterNo + | QTheaterCancel + | QTheaterStatus + +QTheaterOptions → + QTheaterGeneralOptions + | QTheaterShowOptions + | QTheaterDateOptions + | QTheaterRowOptions + | QTheaterSeatOptions + +QTheaterGeneralOptions → + "hverjir"? "eru"? "valmöguleikarnir" + | "hvert" "er" "úrvalið" + | "hvað" "er" "í" "boði" + +QTheaterShowOptions → + "hvaða" "sýningar" "eru" "í" "boði" + +QTheaterDateOptions → + "hvaða" "dagsetningar" "eru" "í" "boði" + | "hvaða" "dagar" "eru" "í" "boði" + | "hvaða" "dagsetningar" "er" "hægt" "að" "velja" "á" "milli" + +QTheaterRowOptions → + "hvaða" "raðir" "eru" QTheaterIBodiLausar + | "hvaða" "röð" "er" QTheaterIBodiLausar + | "hvaða" "bekkir" "eru" QTheaterIBodiLausar + | "hvaða" "bekkur" "er" QTheaterIBodiLausar + +QTheaterSeatOptions → + "hvaða" "sæti" "eru" QTheaterIBodiLausar + | "hverjir" "eru" "sæta" "valmöguleikarnir" + +QTheaterIBodiLausar → + "í" "boði" + | "lausar" + | "lausir" + | "laus" + +QTheaterShowQuery → QTheaterEgVil? "velja" 'sýning' QTheaterShowName + > QTheaterEgVil? "fara" "á" 'sýning' QTheaterShowName + > QTheaterShowOnlyName + +QTheaterShowOnlyName → QTheaterShowName + +QTheaterShowName → Nl + + +QTheaterShowPrice → + "hvað" "kostar" "einn"? 'miði' + | "hvað" "kostar" "1"? 'miði' + | "hvað" "kostar" "einn"? 'miðinn' "á" "sýninguna" + +QTheaterShowLength → + "hvað" "er" "sýningin" "löng" + +QTheaterShowDateQuery → + QTheaterEgVil? "fara"? "á"? 'sýning'? QTheaterShowDate + +QTheaterShowDate → + QTheaterDateTime | QTheaterDate | QTheaterTime + +QTheaterDateTime → + tímapunkturafs + +QTheaterDate → + dagsafs + | dagsföst + +QTheaterTime → + "klukkan"? tími + +QTheaterMoreDates → + "hverjar"? "eru"? "næstu" "þrjár"? QSyningarTimar + | "hverjir" "eru" "næstu" "þrír"? QSyningarTimar + | "get" "ég" "fengið" "að" "sjá" "næstu" "þrjá"? QSyningarTimar + | QTheaterEgVil? "sjá"? "fleiri" QSyningarTimar + | QTheaterEgVil? "sjá"? "næstu" "þrjá"? QSyningarTimar + +QTheaterPreviousDates → + QTheaterEgVil "sjá" "fyrri" QSyningarTimar + | "hvaða" QSyningarTimar "eru" "á" "undan" "þessum"? + | "get" "ég" "fengið" "að" "sjá" QSyningarTimar "á" "undan" "þessum"? + | QTheaterEgVil? "sjá"? QSyningarTimar "á" "undan" "þessum"? + +QSyningarTimar → + 'sýningartíma' + | "dagsetningar" + | "sýningartímana" + +QTheaterShowSeatCountQuery → + QTheaterSeatCountNum + | QTheaterEgVil? "fá"? QTheaterNum "sæti" + | QTheaterEgVil? "fá"? QTheaterNum "miða" + | QTheaterEgVil? "fá"? QTheaterNum "miða" "á" "sýninguna" + +QTheaterSeatCountNum → + to | töl | tala + +QTheaterShowLocationQuery → + QTheaterShowRow + | QTheaterShowSeats + +QTheaterShowRow → + QTheaterRodBekkur + | QTheaterEgVil QTheaterVeljaRod QTheaterRodBekkur + +QTheaterVeljaRod → + "velja" "sæti"? "í"? + | "sitja" "í" + | "fá" "sæti" "í" + | "fá" "sæti" "á" + +QTheaterRodBekkur → + QTheaterRowNum + | QTheaterRodBekk "númer"? QTheaterNum + | QTheaterNum "bekk" + | QTheaterNum "röð" + +QTheaterRowNum → + to | töl | tala + +QTheaterShowSeats → + QTheaterShowSeatsNum + | QTheaterEgVil? "sæti"? "númer"? QTheaterNum "til" QTheaterNum? + | QTheaterEgVil? "sæti" "númer"? QTheaterNum "og" QTheaterNum? + | "ég" "vil" "sitja" "í" "röð" "númer" QTheaterNum + +QTheaterShowSeatsNum → + to | töl | tala + +QTheaterDateOptions → + "hvaða" "dagsetningar" "eru" "í" "boði" + +QTheaterRodBekk → "röð" | "bekk" + +QTheaterEgVil → + "ég"? "vil" + | "ég" "vill" + | "mig" "langar" "að" + | "mig" "langar" "í" + +QTheaterNum → + # to is a declinable number word ('tveir/tvo/tveim/tveggja') + # töl is an undeclinable number word ('sautján') + # tala is a number ('17') + to | töl | tala + +QTheaterYes → "já" "já"* | "endilega" | "já" "takk" | "játakk" | "já" "þakka" "þér" "fyrir" | "já" "takk" "kærlega" "fyrir"? | "jább" "takk"? + +QTheaterNo → "nei" "takk"? | "nei" "nei"* | "neitakk" | "ómögulega" + +QTheaterCancel → "ég" "hætti" "við" + | QTheaterEgVil "hætta" "við" QTheaterPontun? + +QTheaterStatus → + "staðan" + | "hver" "er" "staðan" "á" QTheaterPontun? + | "hver" "er" "staðan" + | "segðu" "mér" "stöðuna" + | "hvernig" "er" "staðan" + | "hvar" "var" "ég" + | "hvert" "var" "ég" 'kominn' + | "hvert" "var" "ég" 'kominn' "í" QTheaterPontun + | "hver" "var" "staðan" "á"? QTheaterPontun + | QTheaterEgVil "halda" "áfram" "með" QTheaterPontun + +QTheaterPontun → + "pöntuninni" + | "leikhús" "pöntuninni" + | "leikhús" "pöntunina" + | "leikhúsmiða" "pöntuninni" + | "leikhúsmiða" "pöntunina" + | "leikhúsmiðapöntunina" + | "leikhúsmiðapöntuninni" + | "leikhús" "miða" "pöntunina" + | "leikhús" "miða" "pöntuninni" \ No newline at end of file diff --git a/queries/ja.py b/queries/ja.py index 5b0bfff1..e0a03c01 100755 --- a/queries/ja.py +++ b/queries/ja.py @@ -39,7 +39,7 @@ from speech.trans import gssml from queries import AnswerTuple, Query, QueryStateDict -from tree import Result, Node +from tree import ParamList, Result, Node from geo import iceprep_for_street from utility import read_txt_api_key, icequote @@ -57,20 +57,20 @@ GRAMMAR = read_grammar_file("ja") -def QJaSubject(node: Node, params: QueryStateDict, result: Result) -> None: +def QJaSubject(node: Node, params: ParamList, result: Result) -> None: result.qkey = result._nominative -def QJaPhoneNum(node: Node, params: QueryStateDict, result: Result) -> None: +def QJaPhoneNum(node: Node, params: ParamList, result: Result) -> None: result.phone_number = result._nominative result.qkey = result.phone_number -def QJaName4PhoneNumQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QJaName4PhoneNumQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = "Name4PhoneNum" -def QJaPhoneNum4NameQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QJaPhoneNum4NameQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = "PhoneNum4Name" diff --git a/queries/js/Libraries/fuse.js b/queries/js/Libraries/fuse.js new file mode 100644 index 00000000..42e7d3b7 --- /dev/null +++ b/queries/js/Libraries/fuse.js @@ -0,0 +1,2240 @@ +/** + * Fuse.js v6.6.2 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2022 Kiro Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Fuse = factory()); +})(this, (function () { 'use strict'; + + function ownKeys(object, enumerableOnly) { + var keys = Object.keys(object); + + if (Object.getOwnPropertySymbols) { + var symbols = Object.getOwnPropertySymbols(object); + enumerableOnly && (symbols = symbols.filter(function (sym) { + return Object.getOwnPropertyDescriptor(object, sym).enumerable; + })), keys.push.apply(keys, symbols); + } + + return keys; + } + + function _objectSpread2(target) { + for (var i = 1; i < arguments.length; i++) { + var source = null != arguments[i] ? arguments[i] : {}; + i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { + _defineProperty(target, key, source[key]); + }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { + Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); + }); + } + + return target; + } + + function _typeof(obj) { + "@babel/helpers - typeof"; + + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { + return typeof obj; + } : function (obj) { + return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; + }, _typeof(obj); + } + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + Object.defineProperty(Constructor, "prototype", { + writable: false + }); + return Constructor; + } + + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + } + + function _inherits(subClass, superClass) { + if (typeof superClass !== "function" && superClass !== null) { + throw new TypeError("Super expression must either be null or a function"); + } + + Object.defineProperty(subClass, "prototype", { + value: Object.create(superClass && superClass.prototype, { + constructor: { + value: subClass, + writable: true, + configurable: true + } + }), + writable: false + }); + if (superClass) _setPrototypeOf(subClass, superClass); + } + + function _getPrototypeOf(o) { + _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { + return o.__proto__ || Object.getPrototypeOf(o); + }; + return _getPrototypeOf(o); + } + + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { + o.__proto__ = p; + return o; + }; + + return _setPrototypeOf(o, p); + } + + function _isNativeReflectConstruct() { + if (typeof Reflect === "undefined" || !Reflect.construct) return false; + if (Reflect.construct.sham) return false; + if (typeof Proxy === "function") return true; + + try { + Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); + return true; + } catch (e) { + return false; + } + } + + function _assertThisInitialized(self) { + if (self === void 0) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return self; + } + + function _possibleConstructorReturn(self, call) { + if (call && (typeof call === "object" || typeof call === "function")) { + return call; + } else if (call !== void 0) { + throw new TypeError("Derived constructors may only return object or undefined"); + } + + return _assertThisInitialized(self); + } + + function _createSuper(Derived) { + var hasNativeReflectConstruct = _isNativeReflectConstruct(); + + return function _createSuperInternal() { + var Super = _getPrototypeOf(Derived), + result; + + if (hasNativeReflectConstruct) { + var NewTarget = _getPrototypeOf(this).constructor; + + result = Reflect.construct(Super, arguments, NewTarget); + } else { + result = Super.apply(this, arguments); + } + + return _possibleConstructorReturn(this, result); + }; + } + + function _toConsumableArray(arr) { + return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); + } + + function _arrayWithoutHoles(arr) { + if (Array.isArray(arr)) return _arrayLikeToArray(arr); + } + + function _iterableToArray(iter) { + if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); + } + + function _unsupportedIterableToArray(o, minLen) { + if (!o) return; + if (typeof o === "string") return _arrayLikeToArray(o, minLen); + var n = Object.prototype.toString.call(o).slice(8, -1); + if (n === "Object" && o.constructor) n = o.constructor.name; + if (n === "Map" || n === "Set") return Array.from(o); + if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); + } + + function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) len = arr.length; + + for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; + + return arr2; + } + + function _nonIterableSpread() { + throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + + function isArray(value) { + return !Array.isArray ? getTag(value) === '[object Array]' : Array.isArray(value); + } // Adapted from: https://github.com/lodash/lodash/blob/master/.internal/baseToString.js + + var INFINITY = 1 / 0; + function baseToString(value) { + // Exit early for strings to avoid a performance hit in some environments. + if (typeof value == 'string') { + return value; + } + + var result = value + ''; + return result == '0' && 1 / value == -INFINITY ? '-0' : result; + } + function toString(value) { + return value == null ? '' : baseToString(value); + } + function isString(value) { + return typeof value === 'string'; + } + function isNumber(value) { + return typeof value === 'number'; + } // Adapted from: https://github.com/lodash/lodash/blob/master/isBoolean.js + + function isBoolean(value) { + return value === true || value === false || isObjectLike(value) && getTag(value) == '[object Boolean]'; + } + function isObject(value) { + return _typeof(value) === 'object'; + } // Checks if `value` is object-like. + + function isObjectLike(value) { + return isObject(value) && value !== null; + } + function isDefined(value) { + return value !== undefined && value !== null; + } + function isBlank(value) { + return !value.trim().length; + } // Gets the `toStringTag` of `value`. + // Adapted from: https://github.com/lodash/lodash/blob/master/.internal/getTag.js + + function getTag(value) { + return value == null ? value === undefined ? '[object Undefined]' : '[object Null]' : Object.prototype.toString.call(value); + } + + var EXTENDED_SEARCH_UNAVAILABLE = 'Extended search is not available'; + var INCORRECT_INDEX_TYPE = "Incorrect 'index' type"; + var LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY = function LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key) { + return "Invalid value for key ".concat(key); + }; + var PATTERN_LENGTH_TOO_LARGE = function PATTERN_LENGTH_TOO_LARGE(max) { + return "Pattern length exceeds max of ".concat(max, "."); + }; + var MISSING_KEY_PROPERTY = function MISSING_KEY_PROPERTY(name) { + return "Missing ".concat(name, " property in key"); + }; + var INVALID_KEY_WEIGHT_VALUE = function INVALID_KEY_WEIGHT_VALUE(key) { + return "Property 'weight' in key '".concat(key, "' must be a positive integer"); + }; + + var hasOwn = Object.prototype.hasOwnProperty; + + var KeyStore = /*#__PURE__*/function () { + function KeyStore(keys) { + var _this = this; + + _classCallCheck(this, KeyStore); + + this._keys = []; + this._keyMap = {}; + var totalWeight = 0; + keys.forEach(function (key) { + var obj = createKey(key); + totalWeight += obj.weight; + + _this._keys.push(obj); + + _this._keyMap[obj.id] = obj; + totalWeight += obj.weight; + }); // Normalize weights so that their sum is equal to 1 + + this._keys.forEach(function (key) { + key.weight /= totalWeight; + }); + } + + _createClass(KeyStore, [{ + key: "get", + value: function get(keyId) { + return this._keyMap[keyId]; + } + }, { + key: "keys", + value: function keys() { + return this._keys; + } + }, { + key: "toJSON", + value: function toJSON() { + return JSON.stringify(this._keys); + } + }]); + + return KeyStore; + }(); + function createKey(key) { + var path = null; + var id = null; + var src = null; + var weight = 1; + var getFn = null; + + if (isString(key) || isArray(key)) { + src = key; + path = createKeyPath(key); + id = createKeyId(key); + } else { + if (!hasOwn.call(key, 'name')) { + throw new Error(MISSING_KEY_PROPERTY('name')); + } + + var name = key.name; + src = name; + + if (hasOwn.call(key, 'weight')) { + weight = key.weight; + + if (weight <= 0) { + throw new Error(INVALID_KEY_WEIGHT_VALUE(name)); + } + } + + path = createKeyPath(name); + id = createKeyId(name); + getFn = key.getFn; + } + + return { + path: path, + id: id, + weight: weight, + src: src, + getFn: getFn + }; + } + function createKeyPath(key) { + return isArray(key) ? key : key.split('.'); + } + function createKeyId(key) { + return isArray(key) ? key.join('.') : key; + } + + function get(obj, path) { + var list = []; + var arr = false; + + var deepGet = function deepGet(obj, path, index) { + if (!isDefined(obj)) { + return; + } + + if (!path[index]) { + // If there's no path left, we've arrived at the object we care about. + list.push(obj); + } else { + var key = path[index]; + var value = obj[key]; + + if (!isDefined(value)) { + return; + } // If we're at the last value in the path, and if it's a string/number/bool, + // add it to the list + + + if (index === path.length - 1 && (isString(value) || isNumber(value) || isBoolean(value))) { + list.push(toString(value)); + } else if (isArray(value)) { + arr = true; // Search each item in the array. + + for (var i = 0, len = value.length; i < len; i += 1) { + deepGet(value[i], path, index + 1); + } + } else if (path.length) { + // An object. Recurse further. + deepGet(value, path, index + 1); + } + } + }; // Backwards compatibility (since path used to be a string) + + + deepGet(obj, isString(path) ? path.split('.') : path, 0); + return arr ? list : list[0]; + } + + var MatchOptions = { + // Whether the matches should be included in the result set. When `true`, each record in the result + // set will include the indices of the matched characters. + // These can consequently be used for highlighting purposes. + includeMatches: false, + // When `true`, the matching function will continue to the end of a search pattern even if + // a perfect match has already been located in the string. + findAllMatches: false, + // Minimum number of characters that must be matched before a result is considered a match + minMatchCharLength: 1 + }; + var BasicOptions = { + // When `true`, the algorithm continues searching to the end of the input even if a perfect + // match is found before the end of the same input. + isCaseSensitive: false, + // When true, the matching function will continue to the end of a search pattern even if + includeScore: false, + // List of properties that will be searched. This also supports nested properties. + keys: [], + // Whether to sort the result list, by score + shouldSort: true, + // Default sort function: sort by ascending score, ascending index + sortFn: function sortFn(a, b) { + return a.score === b.score ? a.idx < b.idx ? -1 : 1 : a.score < b.score ? -1 : 1; + } + }; + var FuzzyOptions = { + // Approximately where in the text is the pattern expected to be found? + location: 0, + // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match + // (of both letters and location), a threshold of '1.0' would match anything. + threshold: 0.6, + // Determines how close the match must be to the fuzzy location (specified above). + // An exact letter match which is 'distance' characters away from the fuzzy location + // would score as a complete mismatch. A distance of '0' requires the match be at + // the exact location specified, a threshold of '1000' would require a perfect match + // to be within 800 characters of the fuzzy location to be found using a 0.8 threshold. + distance: 100 + }; + var AdvancedOptions = { + // When `true`, it enables the use of unix-like search commands + useExtendedSearch: false, + // The get function to use when fetching an object's properties. + // The default will search nested paths *ie foo.bar.baz* + getFn: get, + // When `true`, search will ignore `location` and `distance`, so it won't matter + // where in the string the pattern appears. + // More info: https://fusejs.io/concepts/scoring-theory.html#fuzziness-score + ignoreLocation: false, + // When `true`, the calculation for the relevance score (used for sorting) will + // ignore the field-length norm. + // More info: https://fusejs.io/concepts/scoring-theory.html#field-length-norm + ignoreFieldNorm: false, + // The weight to determine how much field length norm effects scoring. + fieldNormWeight: 1 + }; + var Config = _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({}, BasicOptions), MatchOptions), FuzzyOptions), AdvancedOptions); + + var SPACE = /[^ ]+/g; // Field-length norm: the shorter the field, the higher the weight. + // Set to 3 decimals to reduce index size. + + function norm() { + var weight = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + var mantissa = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 3; + var cache = new Map(); + var m = Math.pow(10, mantissa); + return { + get: function get(value) { + var numTokens = value.match(SPACE).length; + + if (cache.has(numTokens)) { + return cache.get(numTokens); + } // Default function is 1/sqrt(x), weight makes that variable + + + var norm = 1 / Math.pow(numTokens, 0.5 * weight); // In place of `toFixed(mantissa)`, for faster computation + + var n = parseFloat(Math.round(norm * m) / m); + cache.set(numTokens, n); + return n; + }, + clear: function clear() { + cache.clear(); + } + }; + } + + var FuseIndex = /*#__PURE__*/function () { + function FuseIndex() { + var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref$getFn = _ref.getFn, + getFn = _ref$getFn === void 0 ? Config.getFn : _ref$getFn, + _ref$fieldNormWeight = _ref.fieldNormWeight, + fieldNormWeight = _ref$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref$fieldNormWeight; + + _classCallCheck(this, FuseIndex); + + this.norm = norm(fieldNormWeight, 3); + this.getFn = getFn; + this.isCreated = false; + this.setIndexRecords(); + } + + _createClass(FuseIndex, [{ + key: "setSources", + value: function setSources() { + var docs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + this.docs = docs; + } + }, { + key: "setIndexRecords", + value: function setIndexRecords() { + var records = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + this.records = records; + } + }, { + key: "setKeys", + value: function setKeys() { + var _this = this; + + var keys = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + this.keys = keys; + this._keysMap = {}; + keys.forEach(function (key, idx) { + _this._keysMap[key.id] = idx; + }); + } + }, { + key: "create", + value: function create() { + var _this2 = this; + + if (this.isCreated || !this.docs.length) { + return; + } + + this.isCreated = true; // List is Array + + if (isString(this.docs[0])) { + this.docs.forEach(function (doc, docIndex) { + _this2._addString(doc, docIndex); + }); + } else { + // List is Array + this.docs.forEach(function (doc, docIndex) { + _this2._addObject(doc, docIndex); + }); + } + + this.norm.clear(); + } // Adds a doc to the end of the index + + }, { + key: "add", + value: function add(doc) { + var idx = this.size(); + + if (isString(doc)) { + this._addString(doc, idx); + } else { + this._addObject(doc, idx); + } + } // Removes the doc at the specified index of the index + + }, { + key: "removeAt", + value: function removeAt(idx) { + this.records.splice(idx, 1); // Change ref index of every subsquent doc + + for (var i = idx, len = this.size(); i < len; i += 1) { + this.records[i].i -= 1; + } + } + }, { + key: "getValueForItemAtKeyId", + value: function getValueForItemAtKeyId(item, keyId) { + return item[this._keysMap[keyId]]; + } + }, { + key: "size", + value: function size() { + return this.records.length; + } + }, { + key: "_addString", + value: function _addString(doc, docIndex) { + if (!isDefined(doc) || isBlank(doc)) { + return; + } + + var record = { + v: doc, + i: docIndex, + n: this.norm.get(doc) + }; + this.records.push(record); + } + }, { + key: "_addObject", + value: function _addObject(doc, docIndex) { + var _this3 = this; + + var record = { + i: docIndex, + $: {} + }; // Iterate over every key (i.e, path), and fetch the value at that key + + this.keys.forEach(function (key, keyIndex) { + var value = key.getFn ? key.getFn(doc) : _this3.getFn(doc, key.path); + + if (!isDefined(value)) { + return; + } + + if (isArray(value)) { + (function () { + var subRecords = []; + var stack = [{ + nestedArrIndex: -1, + value: value + }]; + + while (stack.length) { + var _stack$pop = stack.pop(), + nestedArrIndex = _stack$pop.nestedArrIndex, + _value = _stack$pop.value; + + if (!isDefined(_value)) { + continue; + } + + if (isString(_value) && !isBlank(_value)) { + var subRecord = { + v: _value, + i: nestedArrIndex, + n: _this3.norm.get(_value) + }; + subRecords.push(subRecord); + } else if (isArray(_value)) { + _value.forEach(function (item, k) { + stack.push({ + nestedArrIndex: k, + value: item + }); + }); + } else ; + } + + record.$[keyIndex] = subRecords; + })(); + } else if (isString(value) && !isBlank(value)) { + var subRecord = { + v: value, + n: _this3.norm.get(value) + }; + record.$[keyIndex] = subRecord; + } + }); + this.records.push(record); + } + }, { + key: "toJSON", + value: function toJSON() { + return { + keys: this.keys, + records: this.records + }; + } + }]); + + return FuseIndex; + }(); + function createIndex(keys, docs) { + var _ref2 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}, + _ref2$getFn = _ref2.getFn, + getFn = _ref2$getFn === void 0 ? Config.getFn : _ref2$getFn, + _ref2$fieldNormWeight = _ref2.fieldNormWeight, + fieldNormWeight = _ref2$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref2$fieldNormWeight; + + var myIndex = new FuseIndex({ + getFn: getFn, + fieldNormWeight: fieldNormWeight + }); + myIndex.setKeys(keys.map(createKey)); + myIndex.setSources(docs); + myIndex.create(); + return myIndex; + } + function parseIndex(data) { + var _ref3 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref3$getFn = _ref3.getFn, + getFn = _ref3$getFn === void 0 ? Config.getFn : _ref3$getFn, + _ref3$fieldNormWeight = _ref3.fieldNormWeight, + fieldNormWeight = _ref3$fieldNormWeight === void 0 ? Config.fieldNormWeight : _ref3$fieldNormWeight; + + var keys = data.keys, + records = data.records; + var myIndex = new FuseIndex({ + getFn: getFn, + fieldNormWeight: fieldNormWeight + }); + myIndex.setKeys(keys); + myIndex.setIndexRecords(records); + return myIndex; + } + + function computeScore$1(pattern) { + var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref$errors = _ref.errors, + errors = _ref$errors === void 0 ? 0 : _ref$errors, + _ref$currentLocation = _ref.currentLocation, + currentLocation = _ref$currentLocation === void 0 ? 0 : _ref$currentLocation, + _ref$expectedLocation = _ref.expectedLocation, + expectedLocation = _ref$expectedLocation === void 0 ? 0 : _ref$expectedLocation, + _ref$distance = _ref.distance, + distance = _ref$distance === void 0 ? Config.distance : _ref$distance, + _ref$ignoreLocation = _ref.ignoreLocation, + ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation; + + var accuracy = errors / pattern.length; + + if (ignoreLocation) { + return accuracy; + } + + var proximity = Math.abs(expectedLocation - currentLocation); + + if (!distance) { + // Dodge divide by zero error. + return proximity ? 1.0 : accuracy; + } + + return accuracy + proximity / distance; + } + + function convertMaskToIndices() { + var matchmask = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + var minMatchCharLength = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : Config.minMatchCharLength; + var indices = []; + var start = -1; + var end = -1; + var i = 0; + + for (var len = matchmask.length; i < len; i += 1) { + var match = matchmask[i]; + + if (match && start === -1) { + start = i; + } else if (!match && start !== -1) { + end = i - 1; + + if (end - start + 1 >= minMatchCharLength) { + indices.push([start, end]); + } + + start = -1; + } + } // (i-1 - start) + 1 => i - start + + + if (matchmask[i - 1] && i - start >= minMatchCharLength) { + indices.push([start, i - 1]); + } + + return indices; + } + + // Machine word size + var MAX_BITS = 32; + + function search(text, pattern, patternAlphabet) { + var _ref = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}, + _ref$location = _ref.location, + location = _ref$location === void 0 ? Config.location : _ref$location, + _ref$distance = _ref.distance, + distance = _ref$distance === void 0 ? Config.distance : _ref$distance, + _ref$threshold = _ref.threshold, + threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold, + _ref$findAllMatches = _ref.findAllMatches, + findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches, + _ref$minMatchCharLeng = _ref.minMatchCharLength, + minMatchCharLength = _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng, + _ref$includeMatches = _ref.includeMatches, + includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches, + _ref$ignoreLocation = _ref.ignoreLocation, + ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation; + + if (pattern.length > MAX_BITS) { + throw new Error(PATTERN_LENGTH_TOO_LARGE(MAX_BITS)); + } + + var patternLen = pattern.length; // Set starting location at beginning text and initialize the alphabet. + + var textLen = text.length; // Handle the case when location > text.length + + var expectedLocation = Math.max(0, Math.min(location, textLen)); // Highest score beyond which we give up. + + var currentThreshold = threshold; // Is there a nearby exact match? (speedup) + + var bestLocation = expectedLocation; // Performance: only computer matches when the minMatchCharLength > 1 + // OR if `includeMatches` is true. + + var computeMatches = minMatchCharLength > 1 || includeMatches; // A mask of the matches, used for building the indices + + var matchMask = computeMatches ? Array(textLen) : []; + var index; // Get all exact matches, here for speed up + + while ((index = text.indexOf(pattern, bestLocation)) > -1) { + var score = computeScore$1(pattern, { + currentLocation: index, + expectedLocation: expectedLocation, + distance: distance, + ignoreLocation: ignoreLocation + }); + currentThreshold = Math.min(score, currentThreshold); + bestLocation = index + patternLen; + + if (computeMatches) { + var i = 0; + + while (i < patternLen) { + matchMask[index + i] = 1; + i += 1; + } + } + } // Reset the best location + + + bestLocation = -1; + var lastBitArr = []; + var finalScore = 1; + var binMax = patternLen + textLen; + var mask = 1 << patternLen - 1; + + for (var _i = 0; _i < patternLen; _i += 1) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from the match location we can stray + // at this error level. + var binMin = 0; + var binMid = binMax; + + while (binMin < binMid) { + var _score2 = computeScore$1(pattern, { + errors: _i, + currentLocation: expectedLocation + binMid, + expectedLocation: expectedLocation, + distance: distance, + ignoreLocation: ignoreLocation + }); + + if (_score2 <= currentThreshold) { + binMin = binMid; + } else { + binMax = binMid; + } + + binMid = Math.floor((binMax - binMin) / 2 + binMin); + } // Use the result from this iteration as the maximum for the next. + + + binMax = binMid; + var start = Math.max(1, expectedLocation - binMid + 1); + var finish = findAllMatches ? textLen : Math.min(expectedLocation + binMid, textLen) + patternLen; // Initialize the bit array + + var bitArr = Array(finish + 2); + bitArr[finish + 1] = (1 << _i) - 1; + + for (var j = finish; j >= start; j -= 1) { + var currentLocation = j - 1; + var charMatch = patternAlphabet[text.charAt(currentLocation)]; + + if (computeMatches) { + // Speed up: quick bool to int conversion (i.e, `charMatch ? 1 : 0`) + matchMask[currentLocation] = +!!charMatch; + } // First pass: exact match + + + bitArr[j] = (bitArr[j + 1] << 1 | 1) & charMatch; // Subsequent passes: fuzzy match + + if (_i) { + bitArr[j] |= (lastBitArr[j + 1] | lastBitArr[j]) << 1 | 1 | lastBitArr[j + 1]; + } + + if (bitArr[j] & mask) { + finalScore = computeScore$1(pattern, { + errors: _i, + currentLocation: currentLocation, + expectedLocation: expectedLocation, + distance: distance, + ignoreLocation: ignoreLocation + }); // This match will almost certainly be better than any existing match. + // But check anyway. + + if (finalScore <= currentThreshold) { + // Indeed it is + currentThreshold = finalScore; + bestLocation = currentLocation; // Already passed `loc`, downhill from here on in. + + if (bestLocation <= expectedLocation) { + break; + } // When passing `bestLocation`, don't exceed our current distance from `expectedLocation`. + + + start = Math.max(1, 2 * expectedLocation - bestLocation); + } + } + } // No hope for a (better) match at greater error levels. + + + var _score = computeScore$1(pattern, { + errors: _i + 1, + currentLocation: expectedLocation, + expectedLocation: expectedLocation, + distance: distance, + ignoreLocation: ignoreLocation + }); + + if (_score > currentThreshold) { + break; + } + + lastBitArr = bitArr; + } + + var result = { + isMatch: bestLocation >= 0, + // Count exact matches (those with a score of 0) to be "almost" exact + score: Math.max(0.001, finalScore) + }; + + if (computeMatches) { + var indices = convertMaskToIndices(matchMask, minMatchCharLength); + + if (!indices.length) { + result.isMatch = false; + } else if (includeMatches) { + result.indices = indices; + } + } + + return result; + } + + function createPatternAlphabet(pattern) { + var mask = {}; + + for (var i = 0, len = pattern.length; i < len; i += 1) { + var _char = pattern.charAt(i); + + mask[_char] = (mask[_char] || 0) | 1 << len - i - 1; + } + + return mask; + } + + var BitapSearch = /*#__PURE__*/function () { + function BitapSearch(pattern) { + var _this = this; + + var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref$location = _ref.location, + location = _ref$location === void 0 ? Config.location : _ref$location, + _ref$threshold = _ref.threshold, + threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold, + _ref$distance = _ref.distance, + distance = _ref$distance === void 0 ? Config.distance : _ref$distance, + _ref$includeMatches = _ref.includeMatches, + includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches, + _ref$findAllMatches = _ref.findAllMatches, + findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches, + _ref$minMatchCharLeng = _ref.minMatchCharLength, + minMatchCharLength = _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng, + _ref$isCaseSensitive = _ref.isCaseSensitive, + isCaseSensitive = _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive, + _ref$ignoreLocation = _ref.ignoreLocation, + ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation; + + _classCallCheck(this, BitapSearch); + + this.options = { + location: location, + threshold: threshold, + distance: distance, + includeMatches: includeMatches, + findAllMatches: findAllMatches, + minMatchCharLength: minMatchCharLength, + isCaseSensitive: isCaseSensitive, + ignoreLocation: ignoreLocation + }; + this.pattern = isCaseSensitive ? pattern : pattern.toLowerCase(); + this.chunks = []; + + if (!this.pattern.length) { + return; + } + + var addChunk = function addChunk(pattern, startIndex) { + _this.chunks.push({ + pattern: pattern, + alphabet: createPatternAlphabet(pattern), + startIndex: startIndex + }); + }; + + var len = this.pattern.length; + + if (len > MAX_BITS) { + var i = 0; + var remainder = len % MAX_BITS; + var end = len - remainder; + + while (i < end) { + addChunk(this.pattern.substr(i, MAX_BITS), i); + i += MAX_BITS; + } + + if (remainder) { + var startIndex = len - MAX_BITS; + addChunk(this.pattern.substr(startIndex), startIndex); + } + } else { + addChunk(this.pattern, 0); + } + } + + _createClass(BitapSearch, [{ + key: "searchIn", + value: function searchIn(text) { + var _this$options = this.options, + isCaseSensitive = _this$options.isCaseSensitive, + includeMatches = _this$options.includeMatches; + + if (!isCaseSensitive) { + text = text.toLowerCase(); + } // Exact match + + + if (this.pattern === text) { + var _result = { + isMatch: true, + score: 0 + }; + + if (includeMatches) { + _result.indices = [[0, text.length - 1]]; + } + + return _result; + } // Otherwise, use Bitap algorithm + + + var _this$options2 = this.options, + location = _this$options2.location, + distance = _this$options2.distance, + threshold = _this$options2.threshold, + findAllMatches = _this$options2.findAllMatches, + minMatchCharLength = _this$options2.minMatchCharLength, + ignoreLocation = _this$options2.ignoreLocation; + var allIndices = []; + var totalScore = 0; + var hasMatches = false; + this.chunks.forEach(function (_ref2) { + var pattern = _ref2.pattern, + alphabet = _ref2.alphabet, + startIndex = _ref2.startIndex; + + var _search = search(text, pattern, alphabet, { + location: location + startIndex, + distance: distance, + threshold: threshold, + findAllMatches: findAllMatches, + minMatchCharLength: minMatchCharLength, + includeMatches: includeMatches, + ignoreLocation: ignoreLocation + }), + isMatch = _search.isMatch, + score = _search.score, + indices = _search.indices; + + if (isMatch) { + hasMatches = true; + } + + totalScore += score; + + if (isMatch && indices) { + allIndices = [].concat(_toConsumableArray(allIndices), _toConsumableArray(indices)); + } + }); + var result = { + isMatch: hasMatches, + score: hasMatches ? totalScore / this.chunks.length : 1 + }; + + if (hasMatches && includeMatches) { + result.indices = allIndices; + } + + return result; + } + }]); + + return BitapSearch; + }(); + + var BaseMatch = /*#__PURE__*/function () { + function BaseMatch(pattern) { + _classCallCheck(this, BaseMatch); + + this.pattern = pattern; + } + + _createClass(BaseMatch, [{ + key: "search", + value: function + /*text*/ + search() {} + }], [{ + key: "isMultiMatch", + value: function isMultiMatch(pattern) { + return getMatch(pattern, this.multiRegex); + } + }, { + key: "isSingleMatch", + value: function isSingleMatch(pattern) { + return getMatch(pattern, this.singleRegex); + } + }]); + + return BaseMatch; + }(); + + function getMatch(pattern, exp) { + var matches = pattern.match(exp); + return matches ? matches[1] : null; + } + + var ExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(ExactMatch, _BaseMatch); + + var _super = _createSuper(ExactMatch); + + function ExactMatch(pattern) { + _classCallCheck(this, ExactMatch); + + return _super.call(this, pattern); + } + + _createClass(ExactMatch, [{ + key: "search", + value: function search(text) { + var isMatch = text === this.pattern; + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [0, this.pattern.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^="(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^=(.*)$/; + } + }]); + + return ExactMatch; + }(BaseMatch); + + var InverseExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(InverseExactMatch, _BaseMatch); + + var _super = _createSuper(InverseExactMatch); + + function InverseExactMatch(pattern) { + _classCallCheck(this, InverseExactMatch); + + return _super.call(this, pattern); + } + + _createClass(InverseExactMatch, [{ + key: "search", + value: function search(text) { + var index = text.indexOf(this.pattern); + var isMatch = index === -1; + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'inverse-exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^!"(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^!(.*)$/; + } + }]); + + return InverseExactMatch; + }(BaseMatch); + + var PrefixExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(PrefixExactMatch, _BaseMatch); + + var _super = _createSuper(PrefixExactMatch); + + function PrefixExactMatch(pattern) { + _classCallCheck(this, PrefixExactMatch); + + return _super.call(this, pattern); + } + + _createClass(PrefixExactMatch, [{ + key: "search", + value: function search(text) { + var isMatch = text.startsWith(this.pattern); + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [0, this.pattern.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'prefix-exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^\^"(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^\^(.*)$/; + } + }]); + + return PrefixExactMatch; + }(BaseMatch); + + var InversePrefixExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(InversePrefixExactMatch, _BaseMatch); + + var _super = _createSuper(InversePrefixExactMatch); + + function InversePrefixExactMatch(pattern) { + _classCallCheck(this, InversePrefixExactMatch); + + return _super.call(this, pattern); + } + + _createClass(InversePrefixExactMatch, [{ + key: "search", + value: function search(text) { + var isMatch = !text.startsWith(this.pattern); + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'inverse-prefix-exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^!\^"(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^!\^(.*)$/; + } + }]); + + return InversePrefixExactMatch; + }(BaseMatch); + + var SuffixExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(SuffixExactMatch, _BaseMatch); + + var _super = _createSuper(SuffixExactMatch); + + function SuffixExactMatch(pattern) { + _classCallCheck(this, SuffixExactMatch); + + return _super.call(this, pattern); + } + + _createClass(SuffixExactMatch, [{ + key: "search", + value: function search(text) { + var isMatch = text.endsWith(this.pattern); + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [text.length - this.pattern.length, text.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'suffix-exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^"(.*)"\$$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^(.*)\$$/; + } + }]); + + return SuffixExactMatch; + }(BaseMatch); + + var InverseSuffixExactMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(InverseSuffixExactMatch, _BaseMatch); + + var _super = _createSuper(InverseSuffixExactMatch); + + function InverseSuffixExactMatch(pattern) { + _classCallCheck(this, InverseSuffixExactMatch); + + return _super.call(this, pattern); + } + + _createClass(InverseSuffixExactMatch, [{ + key: "search", + value: function search(text) { + var isMatch = !text.endsWith(this.pattern); + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: [0, text.length - 1] + }; + } + }], [{ + key: "type", + get: function get() { + return 'inverse-suffix-exact'; + } + }, { + key: "multiRegex", + get: function get() { + return /^!"(.*)"\$$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^!(.*)\$$/; + } + }]); + + return InverseSuffixExactMatch; + }(BaseMatch); + + var FuzzyMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(FuzzyMatch, _BaseMatch); + + var _super = _createSuper(FuzzyMatch); + + function FuzzyMatch(pattern) { + var _this; + + var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref$location = _ref.location, + location = _ref$location === void 0 ? Config.location : _ref$location, + _ref$threshold = _ref.threshold, + threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold, + _ref$distance = _ref.distance, + distance = _ref$distance === void 0 ? Config.distance : _ref$distance, + _ref$includeMatches = _ref.includeMatches, + includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches, + _ref$findAllMatches = _ref.findAllMatches, + findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches, + _ref$minMatchCharLeng = _ref.minMatchCharLength, + minMatchCharLength = _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng, + _ref$isCaseSensitive = _ref.isCaseSensitive, + isCaseSensitive = _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive, + _ref$ignoreLocation = _ref.ignoreLocation, + ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation; + + _classCallCheck(this, FuzzyMatch); + + _this = _super.call(this, pattern); + _this._bitapSearch = new BitapSearch(pattern, { + location: location, + threshold: threshold, + distance: distance, + includeMatches: includeMatches, + findAllMatches: findAllMatches, + minMatchCharLength: minMatchCharLength, + isCaseSensitive: isCaseSensitive, + ignoreLocation: ignoreLocation + }); + return _this; + } + + _createClass(FuzzyMatch, [{ + key: "search", + value: function search(text) { + return this._bitapSearch.searchIn(text); + } + }], [{ + key: "type", + get: function get() { + return 'fuzzy'; + } + }, { + key: "multiRegex", + get: function get() { + return /^"(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^(.*)$/; + } + }]); + + return FuzzyMatch; + }(BaseMatch); + + var IncludeMatch = /*#__PURE__*/function (_BaseMatch) { + _inherits(IncludeMatch, _BaseMatch); + + var _super = _createSuper(IncludeMatch); + + function IncludeMatch(pattern) { + _classCallCheck(this, IncludeMatch); + + return _super.call(this, pattern); + } + + _createClass(IncludeMatch, [{ + key: "search", + value: function search(text) { + var location = 0; + var index; + var indices = []; + var patternLen = this.pattern.length; // Get all exact matches + + while ((index = text.indexOf(this.pattern, location)) > -1) { + location = index + patternLen; + indices.push([index, location - 1]); + } + + var isMatch = !!indices.length; + return { + isMatch: isMatch, + score: isMatch ? 0 : 1, + indices: indices + }; + } + }], [{ + key: "type", + get: function get() { + return 'include'; + } + }, { + key: "multiRegex", + get: function get() { + return /^'"(.*)"$/; + } + }, { + key: "singleRegex", + get: function get() { + return /^'(.*)$/; + } + }]); + + return IncludeMatch; + }(BaseMatch); + + var searchers = [ExactMatch, IncludeMatch, PrefixExactMatch, InversePrefixExactMatch, InverseSuffixExactMatch, SuffixExactMatch, InverseExactMatch, FuzzyMatch]; + var searchersLen = searchers.length; // Regex to split by spaces, but keep anything in quotes together + + var SPACE_RE = / +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/; + var OR_TOKEN = '|'; // Return a 2D array representation of the query, for simpler parsing. + // Example: + // "^core go$ | rb$ | py$ xy$" => [["^core", "go$"], ["rb$"], ["py$", "xy$"]] + + function parseQuery(pattern) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + return pattern.split(OR_TOKEN).map(function (item) { + var query = item.trim().split(SPACE_RE).filter(function (item) { + return item && !!item.trim(); + }); + var results = []; + + for (var i = 0, len = query.length; i < len; i += 1) { + var queryItem = query[i]; // 1. Handle multiple query match (i.e, once that are quoted, like `"hello world"`) + + var found = false; + var idx = -1; + + while (!found && ++idx < searchersLen) { + var searcher = searchers[idx]; + var token = searcher.isMultiMatch(queryItem); + + if (token) { + results.push(new searcher(token, options)); + found = true; + } + } + + if (found) { + continue; + } // 2. Handle single query matches (i.e, once that are *not* quoted) + + + idx = -1; + + while (++idx < searchersLen) { + var _searcher = searchers[idx]; + + var _token = _searcher.isSingleMatch(queryItem); + + if (_token) { + results.push(new _searcher(_token, options)); + break; + } + } + } + + return results; + }); + } + + // to a singl match + + var MultiMatchSet = new Set([FuzzyMatch.type, IncludeMatch.type]); + /** + * Command-like searching + * ====================== + * + * Given multiple search terms delimited by spaces.e.g. `^jscript .python$ ruby !java`, + * search in a given text. + * + * Search syntax: + * + * | Token | Match type | Description | + * | ----------- | -------------------------- | -------------------------------------- | + * | `jscript` | fuzzy-match | Items that fuzzy match `jscript` | + * | `=scheme` | exact-match | Items that are `scheme` | + * | `'python` | include-match | Items that include `python` | + * | `!ruby` | inverse-exact-match | Items that do not include `ruby` | + * | `^java` | prefix-exact-match | Items that start with `java` | + * | `!^earlang` | inverse-prefix-exact-match | Items that do not start with `earlang` | + * | `.js$` | suffix-exact-match | Items that end with `.js` | + * | `!.go$` | inverse-suffix-exact-match | Items that do not end with `.go` | + * + * A single pipe character acts as an OR operator. For example, the following + * query matches entries that start with `core` and end with either`go`, `rb`, + * or`py`. + * + * ``` + * ^core go$ | rb$ | py$ + * ``` + */ + + var ExtendedSearch = /*#__PURE__*/function () { + function ExtendedSearch(pattern) { + var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref$isCaseSensitive = _ref.isCaseSensitive, + isCaseSensitive = _ref$isCaseSensitive === void 0 ? Config.isCaseSensitive : _ref$isCaseSensitive, + _ref$includeMatches = _ref.includeMatches, + includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches, + _ref$minMatchCharLeng = _ref.minMatchCharLength, + minMatchCharLength = _ref$minMatchCharLeng === void 0 ? Config.minMatchCharLength : _ref$minMatchCharLeng, + _ref$ignoreLocation = _ref.ignoreLocation, + ignoreLocation = _ref$ignoreLocation === void 0 ? Config.ignoreLocation : _ref$ignoreLocation, + _ref$findAllMatches = _ref.findAllMatches, + findAllMatches = _ref$findAllMatches === void 0 ? Config.findAllMatches : _ref$findAllMatches, + _ref$location = _ref.location, + location = _ref$location === void 0 ? Config.location : _ref$location, + _ref$threshold = _ref.threshold, + threshold = _ref$threshold === void 0 ? Config.threshold : _ref$threshold, + _ref$distance = _ref.distance, + distance = _ref$distance === void 0 ? Config.distance : _ref$distance; + + _classCallCheck(this, ExtendedSearch); + + this.query = null; + this.options = { + isCaseSensitive: isCaseSensitive, + includeMatches: includeMatches, + minMatchCharLength: minMatchCharLength, + findAllMatches: findAllMatches, + ignoreLocation: ignoreLocation, + location: location, + threshold: threshold, + distance: distance + }; + this.pattern = isCaseSensitive ? pattern : pattern.toLowerCase(); + this.query = parseQuery(this.pattern, this.options); + } + + _createClass(ExtendedSearch, [{ + key: "searchIn", + value: function searchIn(text) { + var query = this.query; + + if (!query) { + return { + isMatch: false, + score: 1 + }; + } + + var _this$options = this.options, + includeMatches = _this$options.includeMatches, + isCaseSensitive = _this$options.isCaseSensitive; + text = isCaseSensitive ? text : text.toLowerCase(); + var numMatches = 0; + var allIndices = []; + var totalScore = 0; // ORs + + for (var i = 0, qLen = query.length; i < qLen; i += 1) { + var searchers = query[i]; // Reset indices + + allIndices.length = 0; + numMatches = 0; // ANDs + + for (var j = 0, pLen = searchers.length; j < pLen; j += 1) { + var searcher = searchers[j]; + + var _searcher$search = searcher.search(text), + isMatch = _searcher$search.isMatch, + indices = _searcher$search.indices, + score = _searcher$search.score; + + if (isMatch) { + numMatches += 1; + totalScore += score; + + if (includeMatches) { + var type = searcher.constructor.type; + + if (MultiMatchSet.has(type)) { + allIndices = [].concat(_toConsumableArray(allIndices), _toConsumableArray(indices)); + } else { + allIndices.push(indices); + } + } + } else { + totalScore = 0; + numMatches = 0; + allIndices.length = 0; + break; + } + } // OR condition, so if TRUE, return + + + if (numMatches) { + var result = { + isMatch: true, + score: totalScore / numMatches + }; + + if (includeMatches) { + result.indices = allIndices; + } + + return result; + } + } // Nothing was matched + + + return { + isMatch: false, + score: 1 + }; + } + }], [{ + key: "condition", + value: function condition(_, options) { + return options.useExtendedSearch; + } + }]); + + return ExtendedSearch; + }(); + + var registeredSearchers = []; + function register() { + registeredSearchers.push.apply(registeredSearchers, arguments); + } + function createSearcher(pattern, options) { + for (var i = 0, len = registeredSearchers.length; i < len; i += 1) { + var searcherClass = registeredSearchers[i]; + + if (searcherClass.condition(pattern, options)) { + return new searcherClass(pattern, options); + } + } + + return new BitapSearch(pattern, options); + } + + var LogicalOperator = { + AND: '$and', + OR: '$or' + }; + var KeyType = { + PATH: '$path', + PATTERN: '$val' + }; + + var isExpression = function isExpression(query) { + return !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]); + }; + + var isPath = function isPath(query) { + return !!query[KeyType.PATH]; + }; + + var isLeaf = function isLeaf(query) { + return !isArray(query) && isObject(query) && !isExpression(query); + }; + + var convertToExplicit = function convertToExplicit(query) { + return _defineProperty({}, LogicalOperator.AND, Object.keys(query).map(function (key) { + return _defineProperty({}, key, query[key]); + })); + }; // When `auto` is `true`, the parse function will infer and initialize and add + // the appropriate `Searcher` instance + + + function parse(query, options) { + var _ref3 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}, + _ref3$auto = _ref3.auto, + auto = _ref3$auto === void 0 ? true : _ref3$auto; + + var next = function next(query) { + var keys = Object.keys(query); + var isQueryPath = isPath(query); + + if (!isQueryPath && keys.length > 1 && !isExpression(query)) { + return next(convertToExplicit(query)); + } + + if (isLeaf(query)) { + var key = isQueryPath ? query[KeyType.PATH] : keys[0]; + var pattern = isQueryPath ? query[KeyType.PATTERN] : query[key]; + + if (!isString(pattern)) { + throw new Error(LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key)); + } + + var obj = { + keyId: createKeyId(key), + pattern: pattern + }; + + if (auto) { + obj.searcher = createSearcher(pattern, options); + } + + return obj; + } + + var node = { + children: [], + operator: keys[0] + }; + keys.forEach(function (key) { + var value = query[key]; + + if (isArray(value)) { + value.forEach(function (item) { + node.children.push(next(item)); + }); + } + }); + return node; + }; + + if (!isExpression(query)) { + query = convertToExplicit(query); + } + + return next(query); + } + + function computeScore(results, _ref) { + var _ref$ignoreFieldNorm = _ref.ignoreFieldNorm, + ignoreFieldNorm = _ref$ignoreFieldNorm === void 0 ? Config.ignoreFieldNorm : _ref$ignoreFieldNorm; + results.forEach(function (result) { + var totalScore = 1; + result.matches.forEach(function (_ref2) { + var key = _ref2.key, + norm = _ref2.norm, + score = _ref2.score; + var weight = key ? key.weight : null; + totalScore *= Math.pow(score === 0 && weight ? Number.EPSILON : score, (weight || 1) * (ignoreFieldNorm ? 1 : norm)); + }); + result.score = totalScore; + }); + } + + function transformMatches(result, data) { + var matches = result.matches; + data.matches = []; + + if (!isDefined(matches)) { + return; + } + + matches.forEach(function (match) { + if (!isDefined(match.indices) || !match.indices.length) { + return; + } + + var indices = match.indices, + value = match.value; + var obj = { + indices: indices, + value: value + }; + + if (match.key) { + obj.key = match.key.src; + } + + if (match.idx > -1) { + obj.refIndex = match.idx; + } + + data.matches.push(obj); + }); + } + + function transformScore(result, data) { + data.score = result.score; + } + + function format(results, docs) { + var _ref = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}, + _ref$includeMatches = _ref.includeMatches, + includeMatches = _ref$includeMatches === void 0 ? Config.includeMatches : _ref$includeMatches, + _ref$includeScore = _ref.includeScore, + includeScore = _ref$includeScore === void 0 ? Config.includeScore : _ref$includeScore; + + var transformers = []; + if (includeMatches) transformers.push(transformMatches); + if (includeScore) transformers.push(transformScore); + return results.map(function (result) { + var idx = result.idx; + var data = { + item: docs[idx], + refIndex: idx + }; + + if (transformers.length) { + transformers.forEach(function (transformer) { + transformer(result, data); + }); + } + + return data; + }); + } + + var Fuse$1 = /*#__PURE__*/function () { + function Fuse(docs) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var index = arguments.length > 2 ? arguments[2] : undefined; + + _classCallCheck(this, Fuse); + + this.options = _objectSpread2(_objectSpread2({}, Config), options); + + if (this.options.useExtendedSearch && !true) { + throw new Error(EXTENDED_SEARCH_UNAVAILABLE); + } + + this._keyStore = new KeyStore(this.options.keys); + this.setCollection(docs, index); + } + + _createClass(Fuse, [{ + key: "setCollection", + value: function setCollection(docs, index) { + this._docs = docs; + + if (index && !(index instanceof FuseIndex)) { + throw new Error(INCORRECT_INDEX_TYPE); + } + + this._myIndex = index || createIndex(this.options.keys, this._docs, { + getFn: this.options.getFn, + fieldNormWeight: this.options.fieldNormWeight + }); + } + }, { + key: "add", + value: function add(doc) { + if (!isDefined(doc)) { + return; + } + + this._docs.push(doc); + + this._myIndex.add(doc); + } + }, { + key: "remove", + value: function remove() { + var predicate = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function + /* doc, idx */ + () { + return false; + }; + var results = []; + + for (var i = 0, len = this._docs.length; i < len; i += 1) { + var doc = this._docs[i]; + + if (predicate(doc, i)) { + this.removeAt(i); + i -= 1; + len -= 1; + results.push(doc); + } + } + + return results; + } + }, { + key: "removeAt", + value: function removeAt(idx) { + this._docs.splice(idx, 1); + + this._myIndex.removeAt(idx); + } + }, { + key: "getIndex", + value: function getIndex() { + return this._myIndex; + } + }, { + key: "search", + value: function search(query) { + var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, + _ref$limit = _ref.limit, + limit = _ref$limit === void 0 ? -1 : _ref$limit; + + var _this$options = this.options, + includeMatches = _this$options.includeMatches, + includeScore = _this$options.includeScore, + shouldSort = _this$options.shouldSort, + sortFn = _this$options.sortFn, + ignoreFieldNorm = _this$options.ignoreFieldNorm; + var results = isString(query) ? isString(this._docs[0]) ? this._searchStringList(query) : this._searchObjectList(query) : this._searchLogical(query); + computeScore(results, { + ignoreFieldNorm: ignoreFieldNorm + }); + + if (shouldSort) { + results.sort(sortFn); + } + + if (isNumber(limit) && limit > -1) { + results = results.slice(0, limit); + } + + return format(results, this._docs, { + includeMatches: includeMatches, + includeScore: includeScore + }); + } + }, { + key: "_searchStringList", + value: function _searchStringList(query) { + var searcher = createSearcher(query, this.options); + var records = this._myIndex.records; + var results = []; // Iterate over every string in the index + + records.forEach(function (_ref2) { + var text = _ref2.v, + idx = _ref2.i, + norm = _ref2.n; + + if (!isDefined(text)) { + return; + } + + var _searcher$searchIn = searcher.searchIn(text), + isMatch = _searcher$searchIn.isMatch, + score = _searcher$searchIn.score, + indices = _searcher$searchIn.indices; + + if (isMatch) { + results.push({ + item: text, + idx: idx, + matches: [{ + score: score, + value: text, + norm: norm, + indices: indices + }] + }); + } + }); + return results; + } + }, { + key: "_searchLogical", + value: function _searchLogical(query) { + var _this = this; + + var expression = parse(query, this.options); + + var evaluate = function evaluate(node, item, idx) { + if (!node.children) { + var keyId = node.keyId, + searcher = node.searcher; + + var matches = _this._findMatches({ + key: _this._keyStore.get(keyId), + value: _this._myIndex.getValueForItemAtKeyId(item, keyId), + searcher: searcher + }); + + if (matches && matches.length) { + return [{ + idx: idx, + item: item, + matches: matches + }]; + } + + return []; + } + + var res = []; + + for (var i = 0, len = node.children.length; i < len; i += 1) { + var child = node.children[i]; + var result = evaluate(child, item, idx); + + if (result.length) { + res.push.apply(res, _toConsumableArray(result)); + } else if (node.operator === LogicalOperator.AND) { + return []; + } + } + + return res; + }; + + var records = this._myIndex.records; + var resultMap = {}; + var results = []; + records.forEach(function (_ref3) { + var item = _ref3.$, + idx = _ref3.i; + + if (isDefined(item)) { + var expResults = evaluate(expression, item, idx); + + if (expResults.length) { + // Dedupe when adding + if (!resultMap[idx]) { + resultMap[idx] = { + idx: idx, + item: item, + matches: [] + }; + results.push(resultMap[idx]); + } + + expResults.forEach(function (_ref4) { + var _resultMap$idx$matche; + + var matches = _ref4.matches; + + (_resultMap$idx$matche = resultMap[idx].matches).push.apply(_resultMap$idx$matche, _toConsumableArray(matches)); + }); + } + } + }); + return results; + } + }, { + key: "_searchObjectList", + value: function _searchObjectList(query) { + var _this2 = this; + + var searcher = createSearcher(query, this.options); + var _this$_myIndex = this._myIndex, + keys = _this$_myIndex.keys, + records = _this$_myIndex.records; + var results = []; // List is Array + + records.forEach(function (_ref5) { + var item = _ref5.$, + idx = _ref5.i; + + if (!isDefined(item)) { + return; + } + + var matches = []; // Iterate over every key (i.e, path), and fetch the value at that key + + keys.forEach(function (key, keyIndex) { + matches.push.apply(matches, _toConsumableArray(_this2._findMatches({ + key: key, + value: item[keyIndex], + searcher: searcher + }))); + }); + + if (matches.length) { + results.push({ + idx: idx, + item: item, + matches: matches + }); + } + }); + return results; + } + }, { + key: "_findMatches", + value: function _findMatches(_ref6) { + var key = _ref6.key, + value = _ref6.value, + searcher = _ref6.searcher; + + if (!isDefined(value)) { + return []; + } + + var matches = []; + + if (isArray(value)) { + value.forEach(function (_ref7) { + var text = _ref7.v, + idx = _ref7.i, + norm = _ref7.n; + + if (!isDefined(text)) { + return; + } + + var _searcher$searchIn2 = searcher.searchIn(text), + isMatch = _searcher$searchIn2.isMatch, + score = _searcher$searchIn2.score, + indices = _searcher$searchIn2.indices; + + if (isMatch) { + matches.push({ + score: score, + key: key, + value: text, + idx: idx, + norm: norm, + indices: indices + }); + } + }); + } else { + var text = value.v, + norm = value.n; + + var _searcher$searchIn3 = searcher.searchIn(text), + isMatch = _searcher$searchIn3.isMatch, + score = _searcher$searchIn3.score, + indices = _searcher$searchIn3.indices; + + if (isMatch) { + matches.push({ + score: score, + key: key, + value: text, + norm: norm, + indices: indices + }); + } + } + + return matches; + } + }]); + + return Fuse; + }(); + + Fuse$1.version = '6.6.2'; + Fuse$1.createIndex = createIndex; + Fuse$1.parseIndex = parseIndex; + Fuse$1.config = Config; + + { + Fuse$1.parseQuery = parse; + } + + { + register(ExtendedSearch); + } + + var Fuse = Fuse$1; + + return Fuse; + +})); diff --git a/queries/js/Philips_Hue/hub.js b/queries/js/Philips_Hue/hub.js new file mode 100644 index 00000000..fd295607 --- /dev/null +++ b/queries/js/Philips_Hue/hub.js @@ -0,0 +1,70 @@ +"use strict"; + +async function findHub() { + return fetch(`https://discovery.meethue.com`) + .then((resp) => resp.json()) + .then((obj) => { + return obj[0]; + }) + .catch((err) => {}); +} + +async function createNewDeveloper(ipAddress) { + return fetch(`http://${ipAddress}/api`, { + method: "POST", + body: JSON.stringify({ + devicetype: "Embla", + }), + }) + .then((resp) => resp.json()) + .then((obj) => { + return obj[0]; + }) + .catch((err) => {}); +} + +async function storeDevice(data, requestURL) { + return fetch(`http://${requestURL}/register_query_data.api`, { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + }) + .then((resp) => resp.json()) + .then((obj) => { + return obj; + }) + .catch((err) => {}); +} + +async function connectHub(clientID, requestURL) { + let deviceInfo = await findHub(); + + try { + let username = await createNewDeveloper(deviceInfo.internalipaddress); + if (!username.success) { + return "Ýttu á 'Philips' takkann á miðstöðinni og reyndu aftur"; + } + + const data = { + client_id: clientID, + key: "iot", + data: { + iot_lights: { + philips_hue: { + credentials: { + username: username.success.username, + ip_address: deviceInfo.internalipaddress, + }, + }, + }, + }, + }; + + await storeDevice(data, requestURL); + return "Tenging við Philips Hue miðstöðina tókst!"; + } catch (error) { + return "Ekki tókst að tengja Philips Hue miðstöðina."; + } +} diff --git a/queries/js/Philips_Hue/set_lights.js b/queries/js/Philips_Hue/set_lights.js new file mode 100644 index 00000000..3671978a --- /dev/null +++ b/queries/js/Philips_Hue/set_lights.js @@ -0,0 +1,416 @@ +"use strict"; +/** + * An object containing info on a light + * connected to the Philips Hue hub, + * after we restructure it. + * @typedef {Object} Light + * @property {string} ID + * @property {Object} info + * @property {string} info.manufacturername + * @property {string} info.modelid + * @property {Object} info.state + * @property {boolean} info.state.on + * @property {number} info.state.bri + * @property {number} info.state.hue + * @property {number} info.state.sat + * @property {string} info.state.effect + * @property {number[]} info.state.xy + * @property {number} info.state.ct + * @property {string} info.state.alert + * @property {string} info.state.colormode + * @property {string} info.state.mode + * @property {boolean} info.state.reachable + * @property {string} info.type + * @property {string} info.name + * @property {string} info.modelid + * @property {string} info.manufacturername + * @property {string} info.productname + * @property {Object} info.capabilities + * @property {string} info.uniqueid + * @property {string} info.swversion + * @property {string} info.swconfigid + * @property {string} info.productid + */ + +/** + * An object containing info + * on a group of lights. + * @typedef {Object} Group + * @property {string} ID + * @property {Object} info + * @property {string} info.name + * @property {string[]} info.lights + * @property {Object[]} info.sensors + * @property {string} info.type + * @property {Object} info.state + * @property {boolean} info.state.all_on + * @property {boolean} info.state.any_on + * @property {string} info.class + * @property {Object} info.action + */ + +/** + * An object containing info on a scene. + * @typedef {Object} Scene + * @property {string} ID + * @property {Object} info + * @property {string} info.name + * @property {string} info.type + * @property {string} info.group + * @property {string[]} info.lights + * @property {string} info.owner + * @property {boolean} info.recycle + * @property {boolean} info.locked + * @property {string} info.picture + * @property {string} info.image + * @property {string} info.lastupdated + * @property {string} info.version + */ + +/** Fuzzy search function that returns an object in the form of {result: (Object), score: (Number)} + * @template T + * @param {string} query - the search term + * @param {T[]} data - the data to search + * @param {string[]} searchKeys - the key/s for searching the data + * @return {T[]} List of results from search + */ +function fuzzySearch(query, data, searchKeys) { + if (searchKeys === undefined) { + searchKeys = ["info.name"]; + } + // Set default argument for searchKeys + + // Fuzzy search for the query term (returns an array of objects) + /* jshint ignore:start */ + let fuse = new Fuse(data, { + includeScore: true, + keys: searchKeys, + shouldSort: true, + threshold: 0.5, + }); + + // Array of results + let searchResult = fuse.search(query); + return searchResult.map((obj) => { + // Copy score inside item + obj.item.score = obj.score; + // Return item itself + return obj.item; + }); + /* jshint ignore:end */ +} + +/** + * @typedef {Object} APIResponseItem + * @property {Object|undefined} error + * @property {string|undefined} error.type + * @property {string|undefined} error.address + * @property {string|undefined} error.description + * @property {Object|undefined} success + */ + +/** Send a PUT request to the given URL endpoint of the Philips Hue hub. + * @param {string} hub_ip - the IP address of the Philips Hue hub on the local network + * @param {string} username - the username we have registered with the hub + * @param {string} url_endpoint - the relevant URL endpoint of the Hue API + * @param {string} state - JSON encoded body + * @returns {Promise} Response from API + */ +async function put_api_v1(hub_ip, username, url_endpoint, state) { + return fetch(`http://${hub_ip}/api/${username}/${url_endpoint}`, { + method: "PUT", + body: state, + }) + .then((resp) => resp.json()) + .catch((err) => [{ error: { description: "Error contacting hub." } }]); +} + +/** + * Fetch all connected lights for this hub. + * @param {string} hub_ip - the IP address of the Philips Hue hub on the local network + * @param {string} username - the username we have registered with the hub + * @returns {Promise} Promise which resolves to list of all connected lights + */ +async function getAllLights(hub_ip, username) { + return fetch(`http://${hub_ip}/api/${username}/lights`) + .then((resp) => resp.json()) + .then( + // Restructure data to be easier to work with + (data) => Object.keys(data).map((key) => ({ ID: key, info: data[key] })) + ); +} + +/** + * Fetch all light groups for this hub. + * @param {string} hub_ip - the IP address of the Philips Hue hub on the local network + * @param {string} username - the username we have registered with the hub + * @returns {Promise} Promise which resolves to list of all light groups + */ +async function getAllGroups(hub_ip, username) { + return fetch(`http://${hub_ip}/api/${username}/groups`) + .then((resp) => resp.json()) + .then( + // Restructure data to be easier to work with + (data) => Object.keys(data).map((key) => ({ ID: key, info: data[key] })) + ); +} + +/** + * Fetch all scenes for this hub. + * @param {string} hub_ip - the IP address of the Philips Hue hub on the local network + * @param {string} username - the username we have registered with the hub + * @returns {Promise} Promise which resolves to list of registered scenes + */ +async function getAllScenes(hub_ip, username) { + return fetch(`http://${hub_ip}/api/${username}/scenes`) + .then((resp) => resp.json()) + .then( + // Restructure data to be easier to work with + (data) => Object.keys(data).map((key) => ({ ID: key, info: data[key] })) + ); +} + +/** + * Check whether a light objects corresponds + * to an IKEA TRADFRI lightbulb. + * @param {Light} light - light object + * @returns {boolean} True if light is IKEA TRADFRI bulb + */ +function isIKEABulb(light) { + return ( + light.info.manufacturername.toLowerCase().includes("ikea") || + light.info.modelid.toLowerCase().includes("tradfri") + ); +} + +/** + * Target, describing API endpoint along with + * @typedef {Object} TargetObject + * @property {string} api_endpoint + * @property {Light[]} affectedLights + * @property {Group[]} affectedGroups + * @property {(string|undefined)} error + */ + +/** + * For a given light-group query pair, + * find the most appropriate API endpoint. + * @param {string} light - query for a light + * @param {string} group - query for a group + * @param {Light[]} allLights - list of all lights + * @param {Group[]} allGroups - list of all groups + * @returns {TargetObject} Object containing API endpoint and affected lights/groups + */ +function findLights(light, group, allLights, allGroups) { + /** @type {Light[]} */ + let matchedLights = []; + /** @type {Group[]} */ + let matchedGroups = []; + + // Find matching lights + if (light !== "*") { + matchedLights = fuzzySearch(light, allLights); + } + // Find matching groups + if (group !== "*" && group !== "") { + matchedGroups = fuzzySearch(group, allGroups); + } + + // API URL endpoint (different for groups vs lights) + let api_endpoint = null; + if (light === "*" && group === "*") { + api_endpoint = `groups/0/action`; // Special all-lights group + } else { + if (matchedGroups.length === 0 && matchedLights.length === 0) { + // Fallback if no group and light matched + let x = light; + light = group; + group = x; + // Try searching again, but swap light and group queries + if (light !== "*") { + matchedLights = fuzzySearch(light, allLights); + } + if (group !== "*" && group !== "") { + matchedGroups = fuzzySearch(group, allGroups); + } + if (matchedGroups.length === 0 && matchedLights.length === 0) { + return { + api_endpoint: "", + affectedLights: [], + affectedGroups: [], + error: "Ekki tókst að finna ljós.", + }; + } + } + if (matchedLights.length === 0) { + // Found a group + if (light === "*") { + // Target entire group + api_endpoint = `groups/${matchedGroups[0].ID}/action`; + // Update matched lights + matchedLights = allLights.filter((li) => + matchedGroups[0].info.lights.includes(li.ID) + ); + } else { + return { + api_endpoint: "", + affectedLights: [], + affectedGroups: [], + error: `Ekkert ljós fannst í herberginu ${group} með nafnið ${light}.`, + }; + } + } else if (matchedGroups.length === 0) { + // Found a light + api_endpoint = `lights/${matchedLights[0].ID}/state`; + matchedLights = [matchedLights[0]]; + } else { + // Found both, try to intelligently find a light within a group + for (let i1 = 0; i1 < matchedGroups.length; i1++) { + let currGroup = matchedGroups[i1]; + for (let i2 = 0; i2 < matchedLights.length; i2++) { + let currLight = matchedLights[i2]; + if (currGroup.info.lights.includes(currLight.ID)) { + // Found the matched light inside the current group; perfect + api_endpoint = `lights/${currLight.ID}/state`; + matchedLights = [currLight]; + break; + } + } + if (api_endpoint !== null) { + // Found a light, end loop + break; + } + } + } + } + return { + endpoint: api_endpoint, + affectedLights: matchedLights, + affectedGroups: matchedGroups, + }; +} + +/** + * The payload object that is sent, json encoded, + * to the Hue API. + * @typedef {Object} Payload + * @property {boolean} on + * @property {number|undefined} bri + * @property {number|undefined} hue + * @property {number|undefined} sat + * @property {number[]|undefined} xy + * @property {number|undefined} ct + * @property {string|undefined} alert + * @property {string|undefined} scene + * @property {string|undefined} effect + * @property {number|undefined} transitiontime + * @property {number|undefined} bri_inc + * @property {number|undefined} sat_inc + * @property {number|undefined} hue_inc + * @property {number|undefined} ct_inc + * @property {number|undefined} xy_inc + */ + +/** Gets a target for the given query and sets the state of the target to the given state using a fetch request. + * @param {string} hub_ip - the IP address of the Philips Hue hub on the local network + * @param {string} username - the username we have registered with the hub + * @param {string} light - the name of a light, "*" matches anything + * @param {string} group - the name of a group, "*" matches anything + * @param {string} json_data - the JSON encoded state to set the target to e.g. {"on": true} or {"scene": "energize"} + * @return {string} Basic string explaining what happened (in Icelandic). + */ +async function setLights(hub_ip, username, light, group, json_data) { + /** @type {Payload} */ + let parsedState = JSON.parse(json_data); + let promiseList = [ + getAllGroups(hub_ip, username), + getAllLights(hub_ip, username), + // Fetch all scenes if payload includes a scene, + // otherwise have an empty list + parsedState.scene !== undefined ? getAllScenes(hub_ip, username) : Promise.resolve([]) + ]; + + // Get all lights and all groups from the API + // (and all scenes if "scene" was a paramater) + return await Promise.allSettled(promiseList).then(async (resolvedPromises) => { + /** @type {Group[]} */ + let allGroups = resolvedPromises[0].value; + /** @type {Light[]} */ + let allLights = resolvedPromises[1].value; + /** @type {Scene[]} */ + let allScenes = resolvedPromises[2].value; + if (allScenes.length > 0) { + let scenesResults = fuzzySearch(parsedState.scene, allScenes); + if (scenesResults.length === 0) { + return `Ekki tókst að finna senuna ${parsedState.scene}.`; + } + // Change the scene parameter to the scene ID + parsedState.scene = scenesResults[0].ID; + if (group === "") { + // If scene is specified with no group, + // find scene's group and set group variable + for (let g = 0; g < allGroups.length; g++) { + if (allGroups[g].ID === scenesResults[0].info.group) { + group = allGroups[g].info.name; + break; + } + } + } + } + + // Find the lights we want to target + let targetObj = findLights(light, group, allLights, allGroups); + if (targetObj.error !== undefined) { + // Ran into error while trying to find a light + return targetObj.error; + } + + let payload = JSON.stringify(parsedState); + // Send data to API + console.log("Endpoint:", targetObj.endpoint); + console.log("Payload:", payload); + let response = await put_api_v1(hub_ip, username, targetObj.endpoint, payload); + console.log("Server response:", JSON.stringify(response)); + + // Deal with IKEA TRADFRI bug + // (sometimes can't handle more than one change at a time) + if ( + (parsedState.scene || Object.keys(parsedState).length > 2) && + targetObj.affectedLights.some(isIKEABulb) + ) { + let sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + sleep(450).then(() => { + put_api_v1(hub_ip, username, targetObj.endpoint, payload); + }); + } + + // Basic formatting of answers + if (parsedState.scene) { + return "Ég kveikti á senu."; + } + if (parsedState.on === false) { + if (light === "*") { + return "Ég slökkti ljósin."; + } + return "Ég slökkti ljósið."; + } + if (parsedState.on === true && Object.keys(parsedState).length === 1) { + if (light === "*") { + return "Ég kveikti ljósin."; + } + return "Ég kveikti ljósið."; + } + if (parsedState.bri_inc && parsedState.bri_inc > 0) { + return "Ég hækkaði birtuna."; + } + if (parsedState.bri_inc && parsedState.bri_inc < 0) { + return "Ég minnkaði birtuna."; + } + if (parsedState.xy || parsedState.hue) { + return "Ég breytti lit ljóssins."; + } + return "Stillingu ljósa var breytt."; + }).catch((err) => { + return "Ekki náðist samband við Philips Hue miðstöðina."; + }); +} diff --git a/queries/news.py b/queries/news.py index bd615091..c557ffb3 100755 --- a/queries/news.py +++ b/queries/news.py @@ -37,7 +37,7 @@ from speech.trans import gssml from queries import Query, QueryStateDict, AnswerTuple from queries.util import gen_answer, query_json_api, read_grammar_file -from tree import Result, Node +from tree import ParamList, Result, Node _NEWS_QTYPE = "News" @@ -65,8 +65,7 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("news") -# Grammar nonterminal plugins -def QNewsQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QNewsQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = _NEWS_QTYPE diff --git a/queries/opinion.py b/queries/opinion.py index 9b7dd531..a4a4b31b 100755 --- a/queries/opinion.py +++ b/queries/opinion.py @@ -28,7 +28,7 @@ from queries import Query, QueryStateDict from queries.util import gen_answer, read_grammar_file -from tree import Result, Node +from tree import ParamList, Result, Node _OPINION_QTYPE = "Opinion" @@ -45,11 +45,11 @@ GRAMMAR = read_grammar_file("opinion") -def QOpinionQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QOpinionQuery(node: Node, params: ParamList, result: Result) -> None: result["qtype"] = _OPINION_QTYPE -def QOpinionSubject(node: Node, params: QueryStateDict, result: Result) -> None: +def QOpinionSubject(node: Node, params: ParamList, result: Result) -> None: result["subject_nom"] = result._nominative diff --git a/queries/petrol.py b/queries/petrol.py index 163e81d1..808fae77 100755 --- a/queries/petrol.py +++ b/queries/petrol.py @@ -33,7 +33,7 @@ import random from geo import distance -from tree import Result, Node +from tree import ParamList, Result, Node from queries import Query, QueryStateDict from queries.util import ( query_json_api, @@ -89,20 +89,20 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("petrol") -def QPetrolQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QPetrolQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = _PETROL_QTYPE -def QPetrolClosestStation(node: Node, params: QueryStateDict, result: Result) -> None: +def QPetrolClosestStation(node: Node, params: ParamList, result: Result) -> None: result.qkey = "ClosestStation" -def QPetrolCheapestStation(node: Node, params: QueryStateDict, result: Result) -> None: +def QPetrolCheapestStation(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CheapestStation" def QPetrolClosestCheapestStation( - node: Node, params: QueryStateDict, result: Result + node: Node, params: ParamList, result: Result ) -> None: result.qkey = "ClosestCheapestStation" diff --git a/queries/pic.py b/queries/pic.py index 25ec33c3..c8e1f68b 100755 --- a/queries/pic.py +++ b/queries/pic.py @@ -32,7 +32,7 @@ from utility import icequote from queries.util import gen_answer, read_grammar_file from reynir import NounPhrase -from tree import Result, Node +from tree import ParamList, Result, Node from images import get_image_url, Img @@ -65,7 +65,7 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("pic") -def QPicQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QPicQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _PIC_QTYPE @@ -74,14 +74,14 @@ def _preprocess(s: str) -> str: return s -def QPicSubject(node: Node, params: QueryStateDict, result: Result) -> None: +def QPicSubject(node: Node, params: ParamList, result: Result) -> None: n = _preprocess(result._text) nom = NounPhrase(n).nominative or n result.subject = nom result.subject_þgf = result._text -def QPicWrongPictureQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QPicWrongPictureQuery(node: Node, params: ParamList, result: Result) -> None: result.wrong = True diff --git a/queries/places.py b/queries/places.py index c5981476..e5e4e46f 100755 --- a/queries/places.py +++ b/queries/places.py @@ -51,7 +51,7 @@ read_grammar_file, ) from speech.trans import gssml -from tree import Result, Node +from tree import ParamList, Result, Node _PLACES_QTYPE = "Places" @@ -86,27 +86,27 @@ def _fix_placename(pn: str) -> str: return _PLACENAME_MAP.get(p, p) -def QPlacesQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesQuery(node: Node, params: ParamList, result: Result) -> None: result["qtype"] = _PLACES_QTYPE -def QPlacesOpeningHours(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesOpeningHours(node: Node, params: ParamList, result: Result) -> None: result["qkey"] = "OpeningHours" -def QPlacesIsOpen(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesIsOpen(node: Node, params: ParamList, result: Result) -> None: result["qkey"] = "IsOpen" -def QPlacesIsClosed(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesIsClosed(node: Node, params: ParamList, result: Result) -> None: result["qkey"] = "IsClosed" -def QPlacesAddress(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesAddress(node: Node, params: ParamList, result: Result) -> None: result["qkey"] = "PlaceAddress" -def QPlacesSubject(node: Node, params: QueryStateDict, result: Result) -> None: +def QPlacesSubject(node: Node, params: ParamList, result: Result) -> None: result["subject_nom"] = _fix_placename(result._nominative) diff --git a/queries/rand.py b/queries/rand.py index 3a8727a2..5ff699b0 100755 --- a/queries/rand.py +++ b/queries/rand.py @@ -32,7 +32,7 @@ from queries.util import gen_answer, read_grammar_file from queries.arithmetic import add_num, terminal_num from speech.trans import gssml -from tree import Result, Node +from tree import ParamList, Result, Node _RANDOM_QTYPE = "Random" @@ -67,27 +67,27 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("rand") -def QRandomQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandomQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = _RANDOM_QTYPE -def QRandomHeadsOrTails(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandomHeadsOrTails(node: Node, params: ParamList, result: Result) -> None: result.action = "headstails" -def QRandomBetween(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandomBetween(node: Node, params: ParamList, result: Result) -> None: result.action = "randbtwn" -def QRandomDieRoll(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandomDieRoll(node: Node, params: ParamList, result: Result) -> None: result.action = "dieroll" -def QRandomDiceSides(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandomDiceSides(node: Node, params: ParamList, result: Result) -> None: result.dice_sides = 6 -def QRandNumber(node: Node, params: QueryStateDict, result: Result) -> None: +def QRandNumber(node: Node, params: ParamList, result: Result) -> None: d = result.find_descendant(t_base="tala") if d: add_num(terminal_num(d), result) diff --git a/queries/smartlights.py b/queries/smartlights.py new file mode 100755 index 00000000..a82f63ef --- /dev/null +++ b/queries/smartlights.py @@ -0,0 +1,369 @@ +""" + + Greynir: Natural language processing for Icelandic + + Smartlight query response module + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + This query module handles queries related to the control of smartlights. + +""" + +# TODO: add "láttu", "hafðu", "litaðu", "kveiktu" functionality. +# TODO: make the objects of sentences more modular, so that the same structure doesn't need to be written for each action +# TODO: ditto the previous comment. make the initial non-terminals general and go into specifics at the terminal level instead. +# TODO: substitution klósett -> baðherbergi, for common room names and alternative ways of saying +# TODO: Two specified groups or lights. +# TODO: No specified location +# TODO: Fix scene issues +# TODO: Turning on lights without using "turn on" +# TODO: Add functionality for robot-like commands "ljós í eldhúsinu", "rautt í eldhúsinu" +# TODO: Mistakes 'gerðu ljósið kaldara' for the scene 'köld' + +from typing import Any, Callable, Dict, List, Optional, cast, FrozenSet +from typing_extensions import TypedDict + +import logging +import random +import json +from pathlib import Path + +from query import Query, QueryStateDict +from queries import read_jsfile, read_grammar_file +from tree import ParamList, Result, Node, TerminalNode + + +class _Creds(TypedDict): + username: str + ip_address: str + + +class _PhilipsHueData(TypedDict): + credentials: _Creds + + +class _IoTDeviceData(TypedDict): + philips_hue: _PhilipsHueData + + +_SMARTLIGHT_QTYPE = "Smartlights" + +TOPIC_LEMMAS = [ + "ljós", + "lampi", + "útiljós", + "kveikja", + "slökkva", + "litur", + "birta", + "hækka", + "lækka", + "sena", + "stemmning", + "stemming", + "stemning", +] + + +def help_text(lemma: str) -> str: + """Help text to return when query.py is unable to parse a query but + one of the above lemmas is found in it""" + return "Ég skil þig ef þú segir til dæmis: {0}.".format( + random.choice( + ( + "Breyttu lit lýsingarinnar í stofunni í bláan", + "Gerðu ljósið í borðstofunni bjartara", + "Stilltu á bjartasta niðri í kjallara", + "Kveiktu á ljósunum inni í eldhúsi", + "Slökktu á leslampanum", + ) + ) + ) + + +# This module wants to handle parse trees for queries +HANDLE_TREE = True + +# The grammar nonterminals this module wants to handle +QUERY_NONTERMINALS = {"QLight"} + +# Color name to [x,y] coordinates +_COLORS: Dict[str, List[float]] = { + "appelsínugulur": [0.6195, 0.3624], + "bleikur": [0.4443, 0.2006], + "blár": [0.1545, 0.0981], + "fjólublár": [0.2291, 0.0843], + "grænn": [0.2458, 0.6431], + "gulur": [0.4833, 0.4647], + "hvítur": [0.3085, 0.3275], + "ljósblár": [0.1581, 0.2395], + "rauður": [0.7, 0.3], + "sægrænn": [0.1664, 0.4621], +} + +# The context-free grammar for the queries recognized by this plug-in module +GRAMMAR = read_grammar_file( + "smartlights", + color_names=" | ".join(f"'{color}:lo'/fall" for color in _COLORS.keys()), +) + + +# Insert or update payload object +# for the light API (kept in result) +_upsert_payload: Callable[[Result, Dict[str, Any]], None] = ( + lambda r, d: r.__setattr__("payload", d) + if "payload" not in r + else cast(Dict[str, Any], r["payload"]).update(d) +) + + +def QLightQuery(node: Node, params: ParamList, result: Result) -> None: + result.qtype = _SMARTLIGHT_QTYPE + + +def QLightLetThereBeLight(node: Node, params: ParamList, result: Result) -> None: + result.action = "turn_on" + result.everywhere = True + _upsert_payload(result, {"on": True}) + + +def QLightTurnOnLights(node: Node, params: ParamList, result: Result) -> None: + result.action = "turn_on" + _upsert_payload(result, {"on": True}) + + +def QLightTurnOffLights(node: Node, params: ParamList, result: Result) -> None: + result.action = "turn_off" + _upsert_payload(result, {"on": False}) + + +def QLightChangeColor(node: Node, params: ParamList, result: Result) -> None: + result.action = "change_color" + color_hue = _COLORS.get(result.color_name, None) + + if color_hue is not None: + _upsert_payload(result, {"on": True, "xy": color_hue}) + + +def QLightChangeScene(node: Node, params: ParamList, result: Result) -> None: + result.action = "change_scene" + scene_name = result.get("scene_name", None) + + if scene_name is not None: + _upsert_payload(result, {"on": True, "scene": scene_name}) + + +def QLightIncreaseBrightness(node: Node, params: ParamList, result: Result) -> None: + result.action = "increase_brightness" + _upsert_payload(result, {"on": True, "bri_inc": 64}) + + +def QLightDecreaseBrightness(node: Node, params: ParamList, result: Result) -> None: + result.action = "decrease_brightness" + _upsert_payload(result, {"bri_inc": -64}) + + +def QLightCooler(node: Node, params: ParamList, result: Result) -> None: + result.action = "decrease_colortemp" + _upsert_payload(result, {"ct_inc": -30000}) + + +def QLightWarmer(node: Node, params: ParamList, result: Result) -> None: + result.action = "increase_colortemp" + _upsert_payload(result, {"ct_inc": 30000}) + + +def QLightBrightest(node: Node, params: ParamList, result: Result) -> None: + result.action = "increase_brightness" + _upsert_payload(result, {"bri": 255}) + + +def QLightDarkest(node: Node, params: ParamList, result: Result) -> None: + result.action = "decrease_brightness" + _upsert_payload(result, {"bri": 0}) + + +def QLightColorName(node: Node, params: ParamList, result: Result) -> None: + fc = node.first_child(lambda x: True) + if fc: + result["color_name"] = fc.string_self().strip("'").split(":")[0] + + +def QLightSceneName(node: Node, params: ParamList, result: Result) -> None: + result["scene_name"] = result._indefinite + result["changing_scene"] = True + + +def QLightGroupName(node: Node, params: ParamList, result: Result) -> None: + result["group_name"] = result._indefinite + + +def QLightEverywhere(node: Node, params: ParamList, result: Result) -> None: + result["everywhere"] = True + + +def QLightAllLights(node: Node, params: ParamList, result: Result) -> None: + result["everywhere"] = True + + +def QLightLightName(node: Node, params: ParamList, result: Result) -> None: + result["light_name"] = result._indefinite + + +# Used to distinguish queries intended for music/radio/speaker modules +_SPEAKER_WORDS: FrozenSet[str] = frozenset( + ( + "tónlist", + "lag", + "hljóð", + "ljóð", + "hátalari", + "útvarp", + "útvarpsstöð", + "útvarp saga", + "bylgja", + "gullbylgja", + "x-ið", + "léttbylgjan", + "rás", + "rás 1", + "rás 2", + "rondo", + "rondó", + "London", + "rangá", + "fm 957", + "fm957", + "fm-957", + "k-100", + "k 100", + "kk 100", + "k hundrað", + "kk hundrað", + "x977", + "x 977", + "x-977", + "x-ið 977", + "x-ið", + "retro", + "kiss fm", + "flassbakk", + "flassbakk fm", + "útvarp hundraðið", + "útvarp 101", + "útvarp hundraðogeinn", + "útvarp hundrað og einn", + "útvarp hundrað einn", + "útvarp hundrað 1", + ) +) + +# Used when grammar mistakes a generic word +# for lights as the name of a group +_PROBABLY_LIGHT_NAME: FrozenSet[str] = frozenset( + ( + "ljós", + "loftljós", + "gólfljós", + "veggljós", + "lampi", + "lampar", + "borðlampi", + "gólflampi", + "vegglampi", + ) +) + + +def sentence(state: QueryStateDict, result: Result) -> None: + """Called when sentence processing is complete""" + q: Query = state["query"] + + # Extract matched terminals in grammar (used like lemmas in this case) + lemmas = set( + i[0].root(state, result.params) + for i in result.enum_descendants(lambda x: isinstance(x, TerminalNode)) + ) + if not lemmas.isdisjoint(_SPEAKER_WORDS) or result.qtype != _SMARTLIGHT_QTYPE: + # Uses a word that is associated with the smartspeaker module + # (or incorrect qtype) + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + q.set_qtype(result.qtype) + if "action" in result: + q.set_key(result.action) + + try: + # TODO: Caching? + cd = q.client_data("iot") + device_data = ( + cast(Optional[_IoTDeviceData], cd.get("iot_lights")) if cd else None + ) + + bridge_ip: Optional[str] = None + username: Optional[str] = None + if device_data is not None: + # TODO: Error checking + bridge_ip = device_data["philips_hue"]["credentials"]["ip_address"] + username = device_data["philips_hue"]["credentials"]["username"] + + if not device_data or not (bridge_ip and username): + q.set_answer( + {"answer": "Það vantar að tengja Philips Hue miðstöðina."}, + "Það vantar að tengja Philips Hue miðstöðina.", + "Það vantar að tengja filips hjú miðstöðina.", + ) + return + + light = result.get("light_name", "*") + if light == "ljós": + # Non-specific word for light, so we match all + light = "*" + + group = result.get("group_name", "") + if result.get("everywhere"): + # Specifically asked for everywhere, match every group + group = "*" + + # If group or scene name is more like the name of a light + if group in _PROBABLY_LIGHT_NAME: + light, group = group, light + if " sinunni " in q.query_lower: + q.set_beautified_query( + q.beautified_query.replace(" sinunni ", " senunni ") + .replace(" siðunni ", " senunni ") + .replace(" sinuna ", " senuna ") + .replace("Aðgerð", "Gerðu") + .replace("aðgerð", "gerðu") + ) + + q.query_is_command() + q.set_answer( + {"answer": "Skal gert."}, + "Skal gert.", + '', + ) + q.set_command( + read_jsfile(str(Path("Libraries", "fuse.js"))) + + read_jsfile(str(Path("Philips_Hue", "set_lights.js"))) + + f"return await setLights('{bridge_ip}','{username}','{light}','{group}','{json.dumps(result.payload)}');" + ) + + except Exception as e: + logging.warning("Exception while processing iot_hue query: {0}".format(e)) + q.set_error("E_EXCEPTION: {0}".format(e)) + raise diff --git a/queries/smartspeakers.py b/queries/smartspeakers.py new file mode 100644 index 00000000..a1b1adef --- /dev/null +++ b/queries/smartspeakers.py @@ -0,0 +1,255 @@ +""" + + Greynir: Natural language processing for Icelandic + + Smartspeaker query response module + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + This query module handles queries related to the control of smartspeakers. + +""" + +# TODO: make the objects of sentences more modular, so that the same structure doesn't need to be written for each action +# TODO: ditto the previous comment. make the initial non-terminals general and go into specifics at the terminal level instead. +# TODO: substituion klósett, baðherbergi hugmyndÆ senda lista i javascript og profa i röð +# TODO: Embla stores old javascript code cached which has caused errors +# TODO: Cut down javascript sent to Embla +# TODO: Two specified groups or lights. +# TODO: No specified location +# TODO: Fix scene issues + +from typing import Dict, cast + +import logging +import random + +from query import Query, QueryStateDict +from queries import read_grammar_file +from queries.extras.sonos import SonosClient, SonosDeviceData +from tree import NonterminalNode, ParamList, Result, Node + +# Dictionary of radio stations and their stream urls +_RADIO_STREAMS: Dict[str, str] = { + "Rás 1": "http://netradio.ruv.is/ras1.mp3", + "Rás 2": "http://netradio.ruv.is/ras2.mp3", + "Rondó": "http://netradio.ruv.is/rondo.mp3", + "Bylgjan": "https://live.visir.is/hls-radio/bylgjan/playlist.m3u8", + "Léttbylgjan": "https://live.visir.is/hls-radio/lettbylgjan/playlist.m3u8", + "Gullbylgjan": "https://live.visir.is/hls-radio/gullbylgjan/playlist.m3u8", + "80s Bylgjan": "https://live.visir.is/hls-radio/80s/chunklist_DVR.m3u8", + "Íslenska Bylgjan": "https://live.visir.is/hls-radio/islenska/chunklist_DVR.m3u8", + "FM957": "https://live.visir.is/hls-radio/fm957/playlist.m3u8", + "Útvarp Saga": "https://stream.utvarpsaga.is/Hljodver", + "K100": "https://k100streymi.mbl.is/beint/k100/tracks-v1a1/rewind-3600.m3u8", + "X977": "https://live.visir.is/hls-radio/x977/playlist.m3u8", + "Retro": "https://k100straumar.mbl.is/retromobile", + "KissFM": "http://stream3.radio.is:443/kissfm", + "Útvarp 101": "https://stream.101.live/audio/101/chunklist.m3u8", + "Apparatið": "https://live.visir.is/hls-radio/apparatid/chunklist_DVR.m3u8", + "FM Extra": "https://live.visir.is/hls-radio/fmextra/chunklist_DVR.m3u8", + "Útvarp Suðurland": "http://ice-11.spilarinn.is/tsudurlandfm", + "Flashback": "http://stream.radio.is:443/flashback", + "70s Flashback": "http://stream3.radio.is:443/70flashback", + "80s Flashback": "http://stream3.radio.is:443/80flashback", + "90s Flashback": "http://stream3.radio.is:443/90flashback", +} + + +_SPEAKER_QTYPE = "Smartspeakers" + +TOPIC_LEMMAS = [ + "tónlist", + "spila", + "útvarp", + "útvarpsstöð", + "hækka", + "lækka", + "stoppa", + "stöðva", +] + + +def help_text(lemma: str) -> str: + """Help text to return when query.py is unable to parse a query but + one of the above lemmas is found in it""" + return "Ég skil þig ef þú segir til dæmis: {0}.".format( + random.choice( + ( + "Hækkaðu í tónlistinni", + "Kveiktu á tónlist", + ) + ) + ) + + +# This module wants to handle parse trees for queries +HANDLE_TREE = True + +# The grammar nonterminals this module wants to handle +QUERY_NONTERMINALS = {"QSpeaker"} + +# The context-free grammar for the queries recognized by this plug-in module +GRAMMAR = read_grammar_file("smartspeakers") + + +def QSpeaker(node: Node, params: ParamList, result: Result) -> None: + result.qtype = _SPEAKER_QTYPE + + +def QSpeakerResume(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "turn_on" + + +def QSpeakerPause(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "turn_off" + + +def QSpeakerSkipVerb(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "next_song" + + +def QSpeakerNewPlay(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "turn_on" + + +def QSpeakerNewPause(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "turn_off" + + +def QSpeakerNewNext(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "next_song" + + +def QSpeakerNewPrevious(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "prev_song" + + +def QSpeakerIncreaseVerb(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "increase_volume" + + +def QSpeakerDecreaseVerb(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "decrease_volume" + + +def QSpeakerMoreOrHigher(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "increase_volume" + + +def QSpeakerLessOrLower(node: Node, params: ParamList, result: Result) -> None: + result.qkey = "decrease_volume" + + +def QSpeakerMusicWord(node: Node, params: ParamList, result: Result) -> None: + result.target = "music" + + +def QSpeakerSpeakerWord(node: Node, params: ParamList, result: Result) -> None: + result.target = "speaker" + + +def QSpeakerPlayRadio(node: Node, params: ParamList, result: Result) -> None: + result.target = "radio" + result.qkey = "radio" + + +def QSpeakerGroupName(node: Node, params: ParamList, result: Result) -> None: + result.group_name = result._indefinite + + +def QSpeakerRadioStation(node: Node, params: ParamList, result: Result) -> None: + child = cast(NonterminalNode, node.child) + station = child.nt_base.replace("QSpeaker", "").replace("_", " ") + print("STATION IS ", station) + result.target = "radio" + result.station = station + + +def sentence(state: QueryStateDict, result: Result) -> None: + """Called when sentence processing is complete""" + q: Query = state["query"] + + if result.get("qtype") != _SPEAKER_QTYPE or not q.client_id: + q.set_error("E_QUERY_NOT_UNDERSTOOD") + return + + qk: str = result.qkey + try: + q.set_qtype(result.qtype) + cd = q.client_data("iot") + device_data = None + if cd: + device_data = cd.get("iot_speakers") + if device_data is not None: + sonos_client = SonosClient( + cast(SonosDeviceData, device_data), + q.client_id, + group_name=result.get("group_name"), + ) + + answer: str + if qk == "turn_on": + sonos_client.toggle_play() + answer = "Ég kveikti á tónlist" + elif qk == "turn_off": + sonos_client.toggle_pause() + answer = "Ég slökkti á tónlistinni" + elif qk == "increase_volume": + sonos_client.increase_volume() + answer = "Ég hækkaði í tónlistinni" + elif qk == "decrease_volume": + sonos_client.decrease_volume() + answer = "Ég lækkaði í tónlistinni" + elif qk == "radio": + # TODO: Error checking + station = result.get("station") + radio_url = _RADIO_STREAMS.get(station) + sonos_client.play_radio_stream(radio_url) + answer = "Ég kveikti á útvarpstöðinni" + elif qk == "next_song": + sonos_client.next_song() + answer = "Ég skipti yfir í næsta lag" + elif qk == "prev_song": + sonos_client.prev_song() + answer = "Ég skipti yfir í lagið á undan" + else: + logging.warning("Incorrect qkey in speaker module") + return + + q.query_is_command() + q.set_key(qk) + q.set_beautified_query( + q.beautified_query.replace("London", "Rondó") + .replace(" eydís ", " 80s ") + .replace(" Eydís ", " 80s ") + .replace(" ljóð", " hljóð") + .replace("Stofnaðu ", "Stoppaðu ") + .replace("stofnaðu ", "stoppaðu ") + .replace("Stoppa í", "Stoppaðu") + .replace("stoppa í", "stoppaðu") + ) + q.set_answer( + dict(answer=answer), + answer, + answer.replace("Sonos", "Sónos"), + ) + return + except Exception as e: + logging.warning("Exception answering smartspeaker query: {0}".format(e)) + q.set_error("E_EXCEPTION: {0}".format(e)) + return + + # TODO: Need to add check for if there are no registered devices to an account, probably when initilazing the querydata diff --git a/queries/sunpos.py b/queries/sunpos.py index d84c3247..9b6cdcf9 100755 --- a/queries/sunpos.py +++ b/queries/sunpos.py @@ -30,7 +30,7 @@ from typing import Dict, List, Iterable, Tuple, Optional, Union, cast -from tree import Result, Node +from tree import ParamList, Result, Node from queries import Query, QueryStateDict from queries.util import ( @@ -139,12 +139,12 @@ class _SOLAR_POSITIONS: } -def QSunQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _SUN_QTYPE -def QSunIsWillWas(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunIsWillWas(node: Node, params: ParamList, result: Result) -> None: if result._nominative == "verður": result["will_be"] = True @@ -152,65 +152,65 @@ def QSunIsWillWas(node: Node, params: QueryStateDict, result: Result) -> None: ### QSunPositions ### -def QSunMiðnætti(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunMiðnætti(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.MIÐNÆTTI -def QSunDögun(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunDögun(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.DÖGUN -def QSunBirting(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunBirting(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.BIRTING -def QSunSólris(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunSólris(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.SÓLRIS -def QSunHádegi(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunHádegi(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.HÁDEGI -def QSunSólarlag(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunSólarlag(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.SÓLARLAG -def QSunMyrkur(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunMyrkur(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.MYRKUR -def QSunDagsetur(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunDagsetur(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.DAGSETUR -def QSunSólarhæð(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunSólarhæð(node: Node, params: ParamList, result: Result) -> None: result["solar_position"] = _SOLAR_POSITIONS.SÓLARHÆÐ ### QSunDates ### -def QSunToday(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunToday(node: Node, params: ParamList, result: Result) -> None: result["date"] = datetime.date.today() -def QSunYesterday(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunYesterday(node: Node, params: ParamList, result: Result) -> None: result["date"] = datetime.date.today() - datetime.timedelta(days=1) -def QSunTomorrow(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunTomorrow(node: Node, params: ParamList, result: Result) -> None: result["date"] = datetime.date.today() + datetime.timedelta(days=1) ### QSunLocation ### -def QSunCapitalRegion(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunCapitalRegion(node: Node, params: ParamList, result: Result) -> None: result["city"] = "Reykjavík" -def QSunArbitraryLocation(node: Node, params: QueryStateDict, result: Result) -> None: +def QSunArbitraryLocation(node: Node, params: ParamList, result: Result) -> None: result["city"] = capitalize_placename(result._nominative) diff --git a/queries/unit.py b/queries/unit.py index 4174bdee..b8d72490 100755 --- a/queries/unit.py +++ b/queries/unit.py @@ -36,7 +36,7 @@ from queries import Query, QueryStateDict, to_dative, to_accusative from queries.util import iceformat_float, parse_num, read_grammar_file, is_plural -from tree import Result, Node +from tree import ParamList, Result, Node from speech.trans import gssml # Lemmas of keywords that could indicate that the user is trying to use this module @@ -216,17 +216,17 @@ def help_text(lemma: str) -> str: GRAMMAR = read_grammar_file("unit") -def QUnitConversion(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnitConversion(node: Node, params: ParamList, result: Result) -> None: """Unit conversion query""" result.qtype = "Unit" result.qkey = result.unit_to -def QUnitNumber(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnitNumber(node: Node, params: ParamList, result: Result) -> None: result.number = parse_num(node, result._canonical) -def QUnit(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnit(node: Node, params: ParamList, result: Result) -> None: # Unit in canonical (nominative, singular, indefinite) form unit = result._canonical.lower() # Convert irregular forms ('mílu', 'bollum') to canonical ones @@ -235,14 +235,14 @@ def QUnit(node: Node, params: QueryStateDict, result: Result) -> None: result.unit_nf = result._nominative.lower() -def QUnitTo(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnitTo(node: Node, params: ParamList, result: Result) -> None: result.unit_to = result.unit result.unit_to_nf = result.unit_nf del result["unit"] del result["unit_nf"] -def QUnitFrom(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnitFrom(node: Node, params: ParamList, result: Result) -> None: if "unit" in result: result.unit_from = result.unit result.unit_from_nf = result.unit_nf @@ -251,7 +251,7 @@ def QUnitFrom(node: Node, params: QueryStateDict, result: Result) -> None: del result["unit_nf"] -def QUnitFromPounds(node: Node, params: QueryStateDict, result: Result) -> None: +def QUnitFromPounds(node: Node, params: ParamList, result: Result) -> None: """Special hack for the case of '150 pund' which is tokenized as an amount token""" amount = node.first_child(lambda n: n.has_t_base("amount")) @@ -266,7 +266,7 @@ def QUnitFromPounds(node: Node, params: QueryStateDict, result: Result) -> None: result._nominative = str(result.number).replace(".", ",") + " pund" -def _convert(quantity: float, unit_from: str, unit_to: str) -> Tuple: +def _convert(quantity: float, unit_from: str, unit_to: str) -> Tuple[bool, float, str, float]: """Converts a quantity from unit_from to unit_to, returning a tuple of: valid, result, si_unit, si_quantity""" u_from, factor_from = _UNITS[unit_from] diff --git a/queries/userloc.py b/queries/userloc.py index 35340d49..170f87b5 100755 --- a/queries/userloc.py +++ b/queries/userloc.py @@ -38,7 +38,7 @@ read_grammar_file, ) from speech.trans.num import numbers_to_text -from tree import Result, Node +from tree import ParamList, Result, Node from iceaddr import iceaddr_lookup, postcodes from geo import ( country_name_for_isocode, @@ -62,19 +62,19 @@ GRAMMAR = read_grammar_file("userloc") -def QUserLocationQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QUserLocationQuery(node: Node, params: ParamList, result: Result) -> None: result.qtype = _LOC_QTYPE -def QUserLocationCurrent(node: Node, params: QueryStateDict, result: Result) -> None: +def QUserLocationCurrent(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentLocation" -def QUserLocationPostcode(node: Node, params: QueryStateDict, result: Result) -> None: +def QUserLocationPostcode(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentPostcode" -def QUserLocationCountry(node: Node, params: QueryStateDict, result: Result) -> None: +def QUserLocationCountry(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentCountry" diff --git a/queries/weather.py b/queries/weather.py index 6a79020a..9be87c05 100755 --- a/queries/weather.py +++ b/queries/weather.py @@ -63,7 +63,7 @@ query_json_api, read_grammar_file, ) -from tree import Result, Node +from tree import ParamList, Result, Node from geo import in_iceland, RVK_COORDS, near_capital_region, ICE_PLACENAME_BLACKLIST from iceaddr import placename_lookup # type: ignore from iceweather import observation_for_closest, observation_for_station, forecast_text # type: ignore @@ -438,56 +438,56 @@ def get_umbrella_answer(query: Query, result: Result) -> Optional[AnswerTuple]: return None -def QWeather(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeather(node: Node, params: ParamList, result: Result) -> None: result.qtype = _WEATHER_QTYPE -def QWeatherCapitalRegion(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherCapitalRegion(node: Node, params: ParamList, result: Result) -> None: result["location"] = "capital" -def QWeatherCountry(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherCountry(node: Node, params: ParamList, result: Result) -> None: result["location"] = "general" -def QWeatherOpenLoc(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherOpenLoc(node: Node, params: ParamList, result: Result) -> None: """Store preposition and placename to use in voice description, e.g. "Á Raufarhöfn" """ result["subject"] = result._node.contained_text().title() -def Nl(node: Node, params: QueryStateDict, result: Result) -> None: +def Nl(node: Node, params: ParamList, result: Result) -> None: """Noun phrase containing name of specific location""" result["location"] = cap_first(result._nominative) -def EfLiður(node: Node, params: QueryStateDict, result: Result) -> None: +def EfLiður(node: Node, params: ParamList, result: Result) -> None: """Don't change the case of possessive clauses""" result._nominative = result._text -def FsMeðFallstjórn(node: Node, params: QueryStateDict, result: Result) -> None: +def FsMeðFallstjórn(node: Node, params: ParamList, result: Result) -> None: """Don't change the case of prepositional clauses""" result._nominative = result._text -def QWeatherCurrent(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherCurrent(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentWeather" -def QWeatherWind(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherWind(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentWeather" -def QWeatherForecast(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherForecast(node: Node, params: ParamList, result: Result) -> None: result.qkey = "WeatherForecast" -def QWeatherTemperature(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherTemperature(node: Node, params: ParamList, result: Result) -> None: result.qkey = "CurrentWeather" -def QWeatherUmbrella(node: Node, params: QueryStateDict, result: Result) -> None: +def QWeatherUmbrella(node: Node, params: ParamList, result: Result) -> None: result.qkey = "Umbrella" diff --git a/queries/wiki.py b/queries/wiki.py index 31ff84a5..7ecac03d 100755 --- a/queries/wiki.py +++ b/queries/wiki.py @@ -35,7 +35,7 @@ import random from datetime import datetime, timedelta -from tree import Result, Node +from tree import ParamList, Result, Node from utility import cap_first from speech.trans import gssml from queries import Query, QueryStateDict, ContextDict @@ -115,7 +115,7 @@ def help_text(lemma: str) -> str: ) -def QWikiQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QWikiQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _WIKI_QTYPE result.qkey = result.get("subject_nom") @@ -123,13 +123,13 @@ def QWikiQuery(node: Node, params: QueryStateDict, result: Result) -> None: result["explicit_wikipedia"] = True -def QWikiWhatIsQuery(node: Node, params: QueryStateDict, result: Result) -> None: +def QWikiWhatIsQuery(node: Node, params: ParamList, result: Result) -> None: # Set the query type result.qtype = _WIKI_QTYPE result.qkey = result.get("subject_nom") -def QWikiSubjectNlNf(node: Node, params: QueryStateDict, result: Result) -> None: +def QWikiSubjectNlNf(node: Node, params: ParamList, result: Result) -> None: result["subject_nom"] = result._nominative @@ -137,7 +137,7 @@ def QWikiSubjectNlNf(node: Node, params: QueryStateDict, result: Result) -> None QWikiSubjectNlÞf = QWikiSubjectNlÞgf = QWikiSubjectNlNf -def QWikiPrevSubjectNf(node: Node, params: QueryStateDict, result: Result) -> None: +def QWikiPrevSubjectNf(node: Node, params: ParamList, result: Result) -> None: """Reference to previous result, usually via personal pronouns ('Hvað segir Wikipedía um hann/hana/það?').""" q: Optional[Query] = result.state.get("query") @@ -159,12 +159,12 @@ def QWikiPrevSubjectNf(node: Node, params: QueryStateDict, result: Result) -> No QWikiPrevSubjectÞgf = QWikiPrevSubjectÞf = QWikiPrevSubjectNf -def EfLiður(node: Node, params: QueryStateDict, result: Result) -> None: +def EfLiður(node: Node, params: ParamList, result: Result) -> None: """Don't change the case of possessive clauses""" result._nominative = result._text -def FsMeðFallstjórn(node: Node, params: QueryStateDict, result: Result) -> None: +def FsMeðFallstjórn(node: Node, params: ParamList, result: Result) -> None: """Don't change the case of prepositional clauses""" result._nominative = result._text diff --git a/queries/wip/.keep b/queries/wip/.keep new file mode 100644 index 00000000..e69de29b diff --git a/queries/wip/iot_spotify.py b/queries/wip/iot_spotify.py new file mode 100644 index 00000000..dc58c71c --- /dev/null +++ b/queries/wip/iot_spotify.py @@ -0,0 +1,107 @@ +""" + + Greynir: Natural language processing for Icelandic + + Example of a plain text query processor module. + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + This module is an example of a plug-in query response module + for the Greynir query subsystem. It handles plain text queries, i.e. + ones that do not require parsing the query text. For this purpose + it only needs to implement the handle_plain_text() function, as + shown below. + + +""" +# TODO: Make grammar + +import random +import re + +from query import Query +from queries.extras.spotify import SpotifyClient +from queries import gen_answer + + +def help_text(lemma: str) -> str: + """Help text to return when query.py is unable to parse a query but + one of the above lemmas is found in it""" + return "Ég skil þig ef þú segir til dæmis: {0}.".format( + random.choice(("Spilaðu Þorparann með Pálma Gunnarssyni",)) + ) + + +# The context-free grammar for the queries recognized by this plug-in module + +_SPOTIFY_REGEXES = [ + # r"^spilaðu ([\w|\s]+) með ([\w|\s]+) á spotify?$", + # r"^spilaðu ([\w|\s]+) á spotify$", + # r"^spilaðu ([\w|\s]+) á spotify", + r"^spilaðu plötuna ([\w|\s]+) með ([\w|\s]+)$", + r"^spilaðu lagið ([\w|\s]+) með ([\w|\s]+)$", + r"^spilaðu ([\w|\s]+) með ([\w|\s]+)$", + # r"^spilaðu plötuna ([\w|\s]+)$ með ([\w|\s]+)$ á spotify$", +] + + +def handle_plain_text(q: Query) -> bool: + """Handle a plain text query requesting Spotify to play a specific song by a specific artist.""" + ql = q.query_lower.strip().rstrip("?") + + for rx in _SPOTIFY_REGEXES: + m = re.search(rx, ql) + if m: + song_name = m.group(1) + artist_name = m.group(2).strip() + print("SONG NAME :", song_name) + print("ARTIST NAME :", artist_name) + try: + device_data = q.client_data("iot")["iot_streaming"]["spotify"] + except AttributeError: + device_data = None + if "plötuna" in ql: + album_name = m.group(1) + else: + album_name = None + if device_data is not None: + client_id = str(q.client_id) + spotify_client = SpotifyClient( + device_data, + client_id, + song_name=song_name or None, + artist_name=artist_name, + album_name=album_name or None, + ) + if album_name != None: + song_url = spotify_client.get_album_by_artist() + song_url = spotify_client.get_first_track_on_album() + response = spotify_client.play_song_on_device() + else: + song_url = spotify_client.get_song_by_artist() + response = spotify_client.play_song_on_device() + + answer = "Ég set það í gang." + if response is None: + q.set_url(song_url) + q.set_answer({"answer": answer}, answer, "") + return True + + else: + answer = "Það vantar að tengja Spotify aðgang." + q.set_answer(*gen_answer(answer)) + return True + return False diff --git a/queries/yulelads.py b/queries/yulelads.py index 51f9b34f..214748ae 100755 --- a/queries/yulelads.py +++ b/queries/yulelads.py @@ -29,7 +29,7 @@ from datetime import datetime from queries import Query, QueryStateDict -from tree import Result, Node, TerminalNode +from tree import ParamList, Result, Node, TerminalNode from queries.util import read_grammar_file from speech.trans.num import numbers_to_ordinal @@ -146,34 +146,34 @@ def help_text(lemma: str) -> str: ) -def QYuleDate(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleDate(node: Node, params: ParamList, result: Result) -> None: """Query for date when a particular yule lad appears""" result.qtype = "YuleDate" result.qkey = result.yule_lad -def QYuleLad(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleLad(node: Node, params: ParamList, result: Result) -> None: """Query for which yule lad appears on a particular date""" result.qtype = "YuleLad" result.qkey = str(result.lad_date) -def QYuleLadFirst(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleLadFirst(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = "Stekkjarstaur" result.lad_date = 12 -def QYuleLadLast(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleLadLast(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = "Kertasníkir" result.lad_date = 24 -def QYuleLadName(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleLadName(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = result._nominative result.lad_date = _YULE_LADS_BY_NAME[result.yule_lad] -def QYuleNumberOrdinal(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleNumberOrdinal(node: Node, params: ParamList, result: Result) -> None: ordinal = node.first_child(lambda n: True) if ordinal is not None: result.lad_date = int(ordinal.contained_number or 0) @@ -188,7 +188,7 @@ def QYuleNumberOrdinal(node: Node, params: QueryStateDict, result: Result) -> No result.invalid_date = True -def QYuleValidOrdinal(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleValidOrdinal(node: Node, params: ParamList, result: Result) -> None: result.lad_date = _ORDINAL_TO_DATE[result._text] if 11 <= result.lad_date <= 23: # If asking about December 11, reply with the @@ -197,23 +197,23 @@ def QYuleValidOrdinal(node: Node, params: QueryStateDict, result: Result) -> Non result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleInvalidOrdinal(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleInvalidOrdinal(node: Node, params: ParamList, result: Result) -> None: result.lad_date = _ORDINAL_TO_DATE[result._text] result.yule_lad = None result.invalid_date = True -def QYuleDay23(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleDay23(node: Node, params: ParamList, result: Result) -> None: result.lad_date = 24 # Yes, correct result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleDay24(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleDay24(node: Node, params: ParamList, result: Result) -> None: result.lad_date = 24 # Yes, correct result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleToday(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleToday(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = None result.lad_date = datetime.utcnow().day if not (11 <= result.lad_date <= 24): @@ -226,7 +226,7 @@ def QYuleToday(node: Node, params: QueryStateDict, result: Result) -> None: result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleTomorrow(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleTomorrow(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = None result.lad_date = datetime.utcnow().day + 1 if not (11 <= result.lad_date <= 24): @@ -239,11 +239,11 @@ def QYuleTomorrow(node: Node, params: QueryStateDict, result: Result) -> None: result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleTwentyPart(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleTwentyPart(node: Node, params: ParamList, result: Result) -> None: result.twenty_part = _TWENTY_PART[result._text] -def QYuleTwentyOrdinal(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleTwentyOrdinal(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = None result.lad_date = 0 num_node = node.first_child(lambda n: True) @@ -265,7 +265,7 @@ def QYuleTwentyOrdinal(node: Node, params: QueryStateDict, result: Result) -> No result.yule_lad = _YULE_LADS_BY_DATE.get(result.lad_date) -def QYuleDateRel(node: Node, params: QueryStateDict, result: Result) -> None: +def QYuleDateRel(node: Node, params: ParamList, result: Result) -> None: result.yule_lad = None daterel = node.first_child(lambda n: True) if daterel is not None: diff --git a/query.py b/query.py new file mode 100755 index 00000000..3063a05a --- /dev/null +++ b/query.py @@ -0,0 +1,1701 @@ +""" + + Greynir: Natural language processing for Icelandic + + Query module + + Copyright (C) 2022 Miðeind ehf. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see http://www.gnu.org/licenses/. + + + This module implements a query processor that operates on queries + in the form of parse trees and returns the results requested, + if the query is valid and understood. + +""" + +from typing import ( + ChainMap as ChainMapType, + DefaultDict, + Optional, + Sequence, + Set, + Tuple, + List, + Dict, + Callable, + Iterator, + Iterable, + Union, + Any, + Mapping, + cast, +) +from typing_extensions import Protocol + +from types import FunctionType, ModuleType + +import importlib +import logging +from copy import deepcopy +from datetime import datetime, timedelta +import json +import re +import random +from collections import defaultdict, ChainMap +from itertools import takewhile + +from settings import Settings + +from db import SessionContext, Session, desc +from db.models import Query as QueryRow +from db.models import QueryData, QueryLog +from queries.extras.dialogue import ( + DialogueStateManager as DSM, +) + +from reynir import TOK, Tok, tokenize, correct_spaces +from reynir.fastparser import ( + Fast_Parser, + ParseForestDumper, + ParseError, + ffi, # type: ignore +) +from tokenizer import BIN_Tuple +from reynir.binparser import BIN_Grammar, BIN_Token +from reynir.reducer import ( + NULL_SC, + ChildDict, + KeyTuple, + ParseForestReducer, + Reducer, + ResultDict, + ScoreDict, +) +from reynir.bindb import GreynirBin +from reynir.fastparser import Node as SPPF_Node +from reynir.grammar import GrammarError, Nonterminal +from islenska.bindb import BinFilterFunc + +from tree import ProcEnv, Tree, TreeStateDict, Node, Result + +# from nertokenizer import recognize_entities +from images import get_image_url +from utility import QUERIES_DIR, modules_in_dir, QUERIES_UTIL_DIR +from geo import LatLonTuple + + +# Query response +ResponseDict = Dict[str, Any] +ResponseMapping = Mapping[str, Any] +ResponseType = Union[ResponseDict, List[ResponseDict]] + +# Query context +ContextDict = Dict[str, Any] + +# Client data +ClientDataDict = Dict[str, Union[str, int, float, bool, Dict[str, str]]] + +# Answer tuple (corresponds to parameter list of Query.set_answer()) +AnswerTuple = Tuple[ResponseType, str, Optional[str]] + +LookupFunc = Callable[[str], Tuple[str, List[BIN_Tuple]]] + +HelpFunc = Callable[[str], str] + + +class QueryStateDict(TreeStateDict): + query: "Query" + names: Dict[str, str] + + +class CastFunc(Protocol): + def __call__(self, w: str, *, filter_func: Optional[BinFilterFunc] = None) -> str: + ... + + +# The grammar root nonterminal for queries; see Greynir.grammar in GreynirPackage +_QUERY_ROOT = "QueryRoot" + +# A fixed preamble that is inserted before the concatenated query grammar fragments +_GRAMMAR_PREAMBLE = """ + +QueryRoot → + Query + +# Mark the QueryRoot nonterminal as a root in the grammar +$root(QueryRoot) + +# Keep all child families of Query, i.e. all possible query +# trees, rather than just the highest-scoring one + +$tag(no_reduce) Query + +""" + +# Query prefixes that we cut off before further processing +# The 'bæjarblað'/'hæðarblað' below is a common misunderstanding by the Google ASR +_IGNORED_QUERY_PREFIXES = ( + "embla", + "hæ embla", + "hey embla", + "sæl embla", + "bæjarblað", + "hæðarblað", +) +_IGNORED_PREFIX_RE = r"^({0})\s*".format("|".join(_IGNORED_QUERY_PREFIXES)) +# Auto-capitalization corrections +_CAPITALIZATION_REPLACEMENTS = (("í Dag", "í dag"),) + + +def beautify_query(query: str) -> str: + """Return a minimally beautified version of the given query string""" + # Make sure the query starts with an uppercase letter + bq = (query[0].upper() + query[1:]) if query else "" + # Add a question mark if no other ending punctuation is present + if not any(bq.endswith(s) for s in ("?", ".", "!")): + bq += "?" + return bq + + +class QueryGrammar(BIN_Grammar): + + """A subclass of BIN_Grammar that reads its input from + strings obtained from query handler plug-ins in the + queries subdirectory, prefixed by a preamble""" + + def __init__(self) -> None: + super().__init__() + # Enable the 'include_queries' condition + self.set_conditions({"include_queries"}) + + @classmethod + def is_grammar_modified(cls) -> bool: + """Override inherited function to specify that query grammars + should always be reparsed, since the set of plug-in query + handlers may have changed, as well as their grammar fragments.""" + return True + + def read( + self, fname: str, verbose: bool = False, binary_fname: Optional[str] = None + ) -> None: + """Overrides the inherited read() function to supply grammar + text from a file as well as additional grammar fragments + from query processor modules.""" + + def grammar_generator() -> Iterator[str]: + """A generator that yields a grammar file, line-by-line, + followed by grammar additions coming from a string + that has been coalesced from grammar fragments in query + processor modules.""" + with open(fname, "r", encoding="utf-8") as inp: + # Read grammar file line-by-line + for line in inp: + yield line + # Yield the query grammar preamble + grammar_preamble = _GRAMMAR_PREAMBLE.split("\n") + for line in grammar_preamble: + yield line + # Yield grammar additions from plug-ins, if any + grammar_additions = QueryParser.grammar_additions().split("\n") + for line in grammar_additions: + yield line + + try: + # Note that if Settings.DEBUG is True, we always write a fresh + # binary grammar file, regardless of file timestamps. This helps + # in query development, as query grammar fragment strings may change + # without any .grammar source file change (which is the default + # trigger for generating new binary grammar files). + self.read_from_generator( + fname, + grammar_generator(), + verbose, + binary_fname, + force_new_binary=Settings.DEBUG, + ) + except (IOError, OSError): + raise GrammarError("Unable to open or read grammar file", fname, 0) + + +class BannedForestException(Exception): + ... + + +_BANNED_RD: ResultDict = cast(ResultDict, {"sc": 0, "ban": True}) + + +class QueryParseForestReducer(ParseForestReducer): + def __init__(self, grammar: BIN_Grammar, scores: ScoreDict, query: "Query"): + super().__init__(grammar, scores) + self._q = query + + def go(self, root_node: SPPF_Node) -> ResultDict: + """Perform the reduction""" + # Memoization/caching dict, keyed by node and memoization key + visited: Dict[KeyTuple, ResultDict] = dict() + # Current memoization key + current_key = 0 + # Next memoization key to use + next_key = 0 + + def calc_score(w: SPPF_Node) -> ResultDict: + """Navigate from (w, current_key) where w is a node and current_key + is an integer navigation key, carefully controlling the memoization + of already visited nodes. + """ + nonlocal current_key, next_key + # Has this (node, current_key) tuple been memoized? + v = visited.get((w, current_key)) + if v is not None: + # Yes: return the previously calculated result + return v + + # Is this nonterminal banned for this specific query? + if w.nonterminal and self._q.is_nonterminal_banned(w.nonterminal): + # Yes: return ResultDict with ban set to True + return _BANNED_RD + + # We have not seen this (node, current_key) combination before: + # reduce it, calculate its score and memoize it + if w._token is not None: + # Return the score of this terminal option + v = self.visit_token(w) + elif w.is_span and w._families: + # We have a nonempty nonterminal node with one or more families + # of children, i.e. multiple possible derivations + child_scores: ChildDict = defaultdict(lambda: {"sc": 0}) + # Go through each family and calculate its score + for fam_ix, (prod, children) in enumerate(w._families): + # Initialize the score of this family of children, so that productions + # with higher priorities (more negative prio values) get a starting bonus + child_scores[fam_ix]["sc"] = -10 * prod.priority + for ch in children: + if ch is not None: + rd = calc_score(ch) + d = child_scores[fam_ix] + if "ban" in rd: + # If this child/family is completely banned, + # carry its ban status on up into child_scores dict + d["ban"] = rd["ban"] # type: ignore + else: # Otherwise modify score + d["sc"] += rd["sc"] + # Carry information about contained verbs ("so") up the tree + for key in ("so", "sl"): + if key in rd: + if key in d: + d[key].extend(rd[key]) # type: ignore + else: + d[key] = rd[key][:] # type: ignore + + if not child_scores: + # Empty node + return NULL_SC + + nt: Optional[Nonterminal] = w.nonterminal if w.is_completed else None + if len(child_scores) == 1: + # Not ambiguous: only one result, do a shortcut + # Will raise an exception if not exactly one value + [v] = child_scores.values() + else: + # Eliminate all families except the best scoring one + # Sort by ban-status (non-banned first), + # then score in decreasing order, + # and then using the family index + # as a tie-breaker for determinism + s = sorted( + child_scores.items(), + key=lambda x: ( + 0 if x[1].get("ban") else 1, # Non-banned first + x[1]["sc"], # Score (descending) + -x[0], # Index (for determinism) + ), + reverse=True, + ) + + if not s[0][1].get("ban") and s[-1][1].get("ban"): + # We have a blend of non-banned and banned families, + # prune the banned ones (even for no-reduce nonterminals) + w._families = [ + w._families[x[0]] + for x in takewhile(lambda y: not y[1].get("ban"), s) + ] + + # This is the best scoring family + # (and the one with the lowest index + # if there are many with the same score) + ix, v = s[0] + + # Note: at this point the best scoring family + # might be banned or not, we deal with this issue + # when returning the root score from go() + + # If the node nonterminal is marked as "no_reduce", + # we leave the child families in place. This feature + # is used in query processing. + if nt is None or not nt.no_reduce: + # And now for the key action of the reducer: + # Eliminate all other families + w.reduce_to(ix) + + if nt is not None: + # We will be adjusting the result: make sure we do so on + # a separate dict copy (we don't want to clobber the child's dict) + # Get score adjustment for this nonterminal, if any + # (This is the $score(+/-N) pragma from Greynir.grammar) + v["sc"] += self._score_adj.get(nt, 0) + + # The winning family is now the only remaining family + # of children of this node; the others have been culled. + else: + v = NULL_SC + # Memoize the result for this (node, current_key) combination + visited[(w, current_key)] = v + w.score = v["sc"] + return v + + # Start the scoring and reduction process at the root + if root_node is None: + return NULL_SC + + root_score = calc_score(root_node) + if root_score.get("ban"): + # Best family is banned, which means that + # no non-banned families were found + raise BannedForestException("Entire parse forest for this query is banned") + return root_score + + +class QueryReducer(Reducer): + def __init__(self, grammar: BIN_Grammar, query: "Query"): + self._grammar = grammar + self._q = query + + def _reduce(self, w: SPPF_Node, scores: ScoreDict) -> ResultDict: + """Reduce a forest with a root in w based on subtree scores""" + return QueryParseForestReducer(self._grammar, scores, self._q).go(w) + + +class QueryParser(Fast_Parser): + + """A subclass of Fast_Parser, specialized to parse queries""" + + # Override the punctuation that is understood by the parser, + # adding the forward slash ('/') + _UNDERSTOOD_PUNCTUATION = BIN_Token._UNDERSTOOD_PUNCTUATION + "+/" + + _GRAMMAR_BINARY_FILE = Fast_Parser._GRAMMAR_FILE + ".query.bin" + + # Keep a separate grammar class instance and time stamp for + # QueryParser. This Python sleight-of-hand overrides + # class attributes that are defined in BIN_Parser, see binparser.py. + _grammar_ts: Optional[float] = None + _grammar: Optional[QueryGrammar] = None + _grammar_class = QueryGrammar + + # Also keep separate class instances of the C grammar and its timestamp + _c_grammar: Any = cast(Any, ffi).NULL + _c_grammar_ts: Optional[float] = None + + # Store the grammar additions for queries + # (these remain constant for all query parsers, so there is no + # need to store them per-instance) + _grammar_additions = "" + + def __init__(self, grammar_additions: str) -> None: + QueryParser._grammar_additions = grammar_additions + super().__init__(verbose=False, root=_QUERY_ROOT) + + @classmethod + def grammar_additions(cls) -> str: + return cls._grammar_additions + + +class QueryTree(Tree): + + """Extend the tree.Tree class to collect all child families of the + Query nonterminal from a query parse forest""" + + def __init__(self): + super().__init__() + self._query_trees: List[Node] = [] + + def handle_O(self, n: int, s: str) -> None: + """Handle the O (option) tree record""" + assert n == 1 + + def handle_Q(self, n: int) -> None: + """Handle the Q (final) tree record""" + super().handle_Q(n) + # Access the QueryRoot node + root = self.s[1] + # Access the Query node + query = None if root is None else root.child + # The child nodes of the Query node are the valid query parse trees + self._query_trees = [] if query is None else list(query.children()) + + @property + def query_trees(self) -> List[Node]: + """Returns the list of valid query parse trees, i.e. child nodes of Query""" + return self._query_trees + + @property + def query_nonterminals(self) -> Set[str]: + """Return the set of query nonterminals that match this query""" + return set(node.string_self() for node in self._query_trees) + + def process_queries( + self, query: "Query", session: Session, processor: ProcEnv + ) -> bool: + """Process all query trees that the given processor is interested in""" + processor_query_types: Set[str] = processor.get("QUERY_NONTERMINALS", set()) + # Every tree processor must be interested in at least one query type + assert isinstance(processor_query_types, set) + # For development, we allow processors to be disinterested in any query + # assert len(processor_query_types) > 0 + if self.query_nonterminals.isdisjoint(processor_query_types): + # But this processor is not interested in any of the nonterminals + # in this query's parse forest: don't waste more cycles on it + return False + + # Prepare dialogue state manager before processing + dialogue_name: Optional[str] = getattr(processor, "DIALOGUE_NAME", None) + if dialogue_name: + query.dsm.prepare_dialogue(dialogue_name) + + with self.context(session, processor, query=query) as state: + for query_tree in self._query_trees: + print( + "Processing query tree", + query_tree.string_self(), + "in module", + processor['__name__'], + ) + # Is the processor interested in the root nonterminal + # of this query tree? + if query_tree.string_self() in processor_query_types: + # Hand the query tree over to the processor + self.process_sentence(state, query_tree) + if query.has_answer(): + # The processor successfully answered the query: We're done + if dialogue_name: + # Update dialogue data if appropriate + # TODO: This should be hidden within the dsm.get_answer + query.dsm.update_dialogue_data() + return True + return False + + +def _merge_two_dicts(dict_a: Dict[str, Any], dict_b: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively merge two dicts, + updating dict_a with values from dict_b, + leaving other values intact. + """ + for key in dict_b: + if ( + key in dict_a + and isinstance(dict_a[key], dict) + and isinstance(dict_b[key], dict) + ): + _merge_two_dicts(dict_a[key], dict_b[key]) + else: + dict_a[key] = dict_b[key] + return dict_a + + +class Query: + + """A Query is initialized by parsing a query string using QueryRoot as the + grammar root nonterminal. The Query can then be executed by processing + the best parse tree using the nonterminal handlers given above, returning a + result object if successful.""" + + # Processors that handle parse trees + _tree_processors: List[ProcEnv] = [] + # Functions from utility modules, + # facilitating code reuse between query modules + _utility_functions: ChainMapType[str, FunctionType] = ChainMap() + # Handler functions within processors that handle plain text + _text_processors: List[Callable[["Query"], bool]] = [] + # Singleton instance of the query parser + _parser: Optional[QueryParser] = None + # Help texts associated with lemmas + _help_texts: Dict[str, List[HelpFunc]] = dict() + + def __init__( + self, + session: Session, # SQLAlchemy session + query: str, + voice: bool, + auto_uppercase: bool, + location: Optional[LatLonTuple], + client_id: Optional[str], + client_type: Optional[str], + client_version: Optional[str], + ) -> None: + + self._query = q = self._preprocess_query_string(query) + self._session = session + self._location = location + # Prepare a "beautified query" string that can be + # shown in a client user interface. By default, this + # starts with an uppercase letter and ends with a + # question mark, but this can be modified during the + # processing of the query. + self.set_beautified_query(beautify_query(q)) + # Boolean flag for whether this is a voice query + self._voice = voice + self._auto_uppercase = auto_uppercase + self._error: Optional[str] = None + # A detailed answer, which can be a list or a dict + self._response: Optional[ResponseType] = None + # A single "best" displayable text answer + self._answer: Optional[str] = None + # A version of self._answer that can be + # fed to a voice synthesizer + self._voice_answer: Optional[str] = None + self._tree: Optional[QueryTree] = None + self._qtype: Optional[str] = None + self._key: Optional[str] = None + self._toklist: Optional[List[Tok]] = None + # Expiration timestamp, if any + self._expires: Optional[datetime] = None + # URL assocated with query, can be set by query response handler + # and subsequently provided to the remote client + self._url: Optional[str] = None + # Command returned by query + self._command: Optional[str] = None + # Image URL returned by query + self._image: Optional[str] = None + # Client id, if known + self._client_id = client_id + # Client type, if known + self._client_type = client_type + # Client version, if known + self._client_version = client_version + # Source of answer to query + self._source: Optional[str] = None + # Query context, which is None until fetched via self.fetch_context() + # This should be a dict that can be represented in JSON + self._context: Optional[ContextDict] = None + # Banned nonterminals for this query, + # dynamically generated from query modules + self._banned_nonterminals: Set[str] = set() + # The dialogue state manager, used for dialogue modules + self._dsm: DSM = DSM(self._client_id, self._session) + + def _preprocess_query_string(self, q: str) -> str: + """Preprocess the query string prior to further analysis""" + if not q: + return q + qf = re.sub(_IGNORED_PREFIX_RE, "", q, flags=re.IGNORECASE) + # Remove " embla" suffix, if present + qf = re.sub(r"\s+embla$", "", qf, flags=re.IGNORECASE) + # Fix common Google ASR mistake: 'hæ embla' is returned as 'bæjarblað' + if not qf and q == "bæjarblað": + q = "hæ embla" + # If stripping the prefixes results in an empty query, + # just return original query string unmodified. + return qf or q + + @classmethod + def init_class(cls) -> None: + """Initialize singleton data, i.e. the list of query + processor modules and the query parser instance""" + all_procs: List[ModuleType] = [] + tree_procs: List[Tuple[int, ModuleType]] = [] + text_procs: List[Tuple[int, Callable[["Query"], bool]]] = [] + + # Load the query processor modules found in the + # queries directory. The modules can be tree and/or text processors, + # and we sort them into two lists, accordingly. + modnames = modules_in_dir(QUERIES_DIR) + for modname in sorted(modnames): + try: + m = importlib.import_module(modname) + is_proc = False + # Obtain module priority, if any + priority: int = getattr(m, "PRIORITY", 0) + if getattr(m, "HANDLE_TREE", False): + # This is a tree processor + is_proc = True + tree_procs.append((priority, m)) + handle_plain_text = getattr(m, "handle_plain_text", None) + if handle_plain_text is not None: + # This is a text processor: + # store a reference to its handler function + is_proc = True + text_procs.append((priority, handle_plain_text)) + if is_proc: + all_procs.append(m) + except ImportError as e: + logging.error( + "Error importing query processor module {0}: {1}".format(modname, e) + ) + # Sort the processors by descending priority + # so that the higher-priority ones get invoked bfore the lower-priority ones + # We create a ChainMap (processing environment) for each tree processor, + # containing the processors attributes with the utility modules as a fallback + cls._tree_processors = [ + cls.create_processing_env(t[1]) + for t in sorted(tree_procs, key=lambda x: -x[0]) + ] + cls._text_processors = [t[1] for t in sorted(text_procs, key=lambda x: -x[0])] + + # Obtain query grammar fragments from the utility modules and tree processors + grammar_fragments: List[str] = [] + + # Load utility modules + modnames = modules_in_dir(QUERIES_UTIL_DIR) + for modname in sorted(modnames): + try: + um = importlib.import_module(modname) + exported = vars(um) # Get all exported values from module + + # Pop grammar fragment, if any + fragment = exported.pop("GRAMMAR", None) + if fragment and isinstance(fragment, str): + # This utility module has a grammar fragment, + # and probably corresponding nonterminal functions + # We add the grammar fragment to our grammar + grammar_fragments.append(fragment) + # and the nonterminal functions to the shared functions ChainMap, + # ignoring non-callables and underscore (private) attributes + cls._utility_functions.update( + ( + (k, v) + for k, v in exported.items() + if callable(v) and not k.startswith("_") + ) + ) + except ImportError as e: + logging.error( + "Error importing utility module {0}: {1}".format(modname, e) + ) + + for processor in cls._tree_processors: + # Check whether this dialogue/tree processor supplies a query grammar fragment + fragment = processor.pop("GRAMMAR", None) + if fragment and isinstance(fragment, str): + # Looks legit: add it to our list + grammar_fragments.append(fragment) + + # Collect topic lemmas that can be used to provide + # context-sensitive help texts when queries cannot be parsed + help_texts: DefaultDict[str, List[HelpFunc]] = defaultdict(list) + for processor in all_procs: + # Collect topic lemmas and corresponding help text functions + topic_lemmas = getattr(processor, "TOPIC_LEMMAS", None) + if topic_lemmas: + help_text_func = getattr(processor, "help_text", None) + # If topic lemmas are given, a help_text function + # should also be present + assert help_text_func is not None + if help_text_func is not None: + for lemma in topic_lemmas: + help_texts[lemma].append(help_text_func) + cls._help_texts = help_texts + + # Coalesce the grammar additions from the fragments + grammar_additions = "\n".join(grammar_fragments) + # Initialize a singleton parser instance for queries, + # with the nonterminal 'QueryRoot' as the grammar root + cls._parser = QueryParser(grammar_additions) + + @staticmethod + def create_processing_env(processor: ModuleType) -> ProcEnv: + """ + Create a new child of the utility functions ChainMap. + Returns a mapping suitable for parsing query trees, + where the current processor's functions are prioritized over + the shared utility module functions. + """ + return Query._utility_functions.new_child(vars(processor)) + + def _parse(self, toklist: Iterable[Tok]) -> Tuple[ResponseDict, Dict[int, str]]: + """Parse a token list as a query""" + bp = Query._parser + assert bp is not None + num_sent = 0 + num_parsed_sent = 0 + rdc = QueryReducer(bp.grammar, self) + trees: Dict[int, str] = dict() + sent: List[Tok] = [] + + for t in toklist: + if t[0] == TOK.S_BEGIN: + if num_sent > 0: + # A second sentence is beginning: this is not valid for a query + raise ParseError("A query cannot contain more than one sentence") + sent = [] + elif t[0] == TOK.S_END: + slen = len(sent) + if not slen: + continue + num_sent += 1 + # Parse the accumulated sentence + num = 0 + try: + # Parse the sentence + forest = bp.go(sent) + if forest is not None: + num = Fast_Parser.num_combinations(forest) + if num > 1: + # Reduce the resulting forest + forest = rdc.go(forest) + except (ParseError, BannedForestException): + forest = None + num = 0 + if num > 0: + num_parsed_sent += 1 + # Obtain a text representation of the parse tree + assert forest is not None + trees[num_sent] = ParseForestDumper.dump_forest(forest) + + elif t[0] == TOK.P_BEGIN: + pass + elif t[0] == TOK.P_END: + pass + else: + sent.append(t) + result: ResponseDict = dict(num_sent=num_sent, num_parsed_sent=num_parsed_sent) + return result, trees + + @staticmethod + def _query_string_from_toklist(toklist: Iterable[Tok]) -> str: + """Re-create a query string from an auto-capitalized token list""" + actual_q = correct_spaces(" ".join(t.txt for t in toklist if t.txt)) + if actual_q: + # Fix stuff that the auto-capitalization tends to get wrong, + # such as 'í Dag' + for wrong, correct in _CAPITALIZATION_REPLACEMENTS: + actual_q = actual_q.replace(wrong, correct) + # Capitalize the first letter of the query + actual_q = actual_q[0].upper() + actual_q[1:] + # Terminate the query with a question mark, + # if not otherwise terminated + if not any(actual_q.endswith(s) for s in ("?", ".", "!")): + actual_q += "?" + return actual_q + + def parse(self, result: ResponseDict) -> bool: + """Parse the query from its string, returning True if valid""" + self._tree = None # Erase previous tree, if any + self._error = None # Erase previous error, if any + self._qtype = None # Erase previous query type, if any + self._key = None + self._toklist = None + + q = self._query + if not q: + self.set_error("E_EMPTY_QUERY") + return False + + # Tokenize and auto-capitalize the query string, without multiplying numbers together + toklist = list( + tokenize( + q, + auto_uppercase=self._auto_uppercase and q.islower(), + no_multiply_numbers=True, + ) + ) + + actual_q = self._query_string_from_toklist(toklist) + + # Update the beautified query string, as the actual_q string + # probably has more correct capitalization + self.set_beautified_query(actual_q) + + # TODO: We might want to re-tokenize the actual_q string with + # auto_uppercase=False, since we may have fixed capitalization + # errors in _query_string_from_toklist() + + if Settings.DEBUG: + # Log the query string as seen by the parser + print("Query is: '{0}'".format(actual_q)) + + # Fetch banned nonterminals for this query + for t in self._tree_processors: + ban_func = getattr(t, "banned_nonterminals", None) + if ban_func is not None: + ban_func(self) + + try: + parse_result, trees = self._parse(toklist) + except ParseError: + self.set_error("E_PARSE_ERROR") + return False + + if not trees: + # No parse at all + self.set_error("E_NO_PARSE_TREES") + return False + + result.update(parse_result) + + if result["num_sent"] != 1: + # Queries must be one sentence + self.set_error("E_MULTIPLE_SENTENCES") + return False + if result["num_parsed_sent"] != 1: + # Unable to parse the single sentence + self.set_error("E_NO_PARSE") + return False + if 1 not in trees: + # No sentence number 1 + self.set_error("E_NO_FIRST_SENTENCE") + return False + # Looks good + # Store the resulting parsed query as a tree + tree_string = "S1\n" + trees[1] + if Settings.DEBUG: + print(tree_string) + self._tree = QueryTree() + self._tree.load(tree_string) + # Store the token list + self._toklist = toklist + return True + + def execute_from_plain_text(self) -> bool: + """Attempt to execute a plain text query, without having to parse it""" + if not self._query: + return False + # Call the handle_plain_text() function in each text processor, + # until we find one that returns True, or return False otherwise + return any( + handle_plain_text(self) for handle_plain_text in self._text_processors + ) + + def execute_from_tree(self) -> bool: + """Execute the query or queries contained in the previously parsed tree; + return True if successful""" + if self._tree is None: + self.set_error("E_QUERY_NOT_PARSED") + return False + + # TODO: We should probably try the best tree on all processors + # before trying the worse trees, instead of all trees per processor, + # as this sometimes causes issues (e.g. similar parse trees for hue and sonos). + # TODO: Create generator function which dynamically prioritizes processors + + # Try each tree processor in turn, in priority order (highest priority first) + for processor in self._tree_processors: + self._error = None + self._qtype = None + # Process the tree, which has only one sentence, but may + # have multiple matching query nonterminals + # (children of Query in the grammar) + try: + # Note that passing query=self here means that the + # "query" field of the TreeStateDict is populated, + # turning it into a QueryStateDict. + if self._tree.process_queries( + self, + self._session, + processor, + ): + # This processor found an answer, which is already stored + # in the Query object: return True + return True + except Exception as e: + logging.error( + f"Exception in execute_from_tree('{processor.get('__name__', 'UNKNOWN')}') " + f"for query '{self._query}': {repr(e)}" + ) + # No processor was able to answer the query + return False + + def has_answer(self) -> bool: + """Return True if the query currently has an answer""" + return bool(self._answer) and self._error is None + + def last_answer(self, *, within_minutes: int = 5) -> Optional[Tuple[str, str]]: + """Return the last answer given to this client, by default + within the last 5 minutes (0=forever)""" + if not self._client_id: + # Can't find the last answer if no client_id given + return None + # Find the newest non-error, no-repeat query result for this client + q = ( + self._session.query(QueryRow.answer, QueryRow.voice) + .filter(QueryRow.client_id == self._client_id) + .filter(QueryRow.qtype != "Repeat") + .filter(QueryRow.error == None) + ) + if within_minutes > 0: + # Apply a timestamp filter + since = datetime.utcnow() - timedelta(minutes=within_minutes) + q = q.filter(QueryRow.timestamp >= since) + # Sort to get the newest query that fulfills the criteria + last = q.order_by(desc(QueryRow.timestamp)).limit(1).one_or_none() + return None if last is None else (last[0], last[1]) + + def fetch_context(self, *, within_minutes: int = 10) -> Optional[ContextDict]: + """Return the context from the last answer given to this client, + by default within the last 10 minutes (0=forever)""" + if not self._client_id: + # Can't find the last answer if no client_id given + return None + # Find the newest non-error, no-repeat query result for this client + q = ( + self._session.query(QueryRow.context) + .filter(QueryRow.client_id == self._client_id) + .filter(QueryRow.qtype != "Repeat") + .filter(QueryRow.error == None) + ) + if within_minutes > 0: + # Apply a timestamp filter + since = datetime.utcnow() - timedelta(minutes=within_minutes) + q = q.filter(QueryRow.timestamp >= since) + # Sort to get the newest query that fulfills the criteria + ctx = cast( + Optional[Sequence[ContextDict]], + q.order_by(desc(QueryRow.timestamp)).limit(1).one_or_none(), + ) + # This function normally returns a dict that has been decoded from JSON + return None if ctx is None else ctx[0] + + @property + def query(self) -> str: + """The query text, in its original form""" + return self._query + + @property + def query_lower(self) -> str: + """The query text, all lower case""" + return self._query.lower() + + @property + def beautified_query(self) -> str: + """Return the query string that will be reflected back to the client""" + return self._beautified_query + + def set_beautified_query(self, q: str) -> None: + """Set the query string that will be reflected back to the client""" + self._beautified_query = ( + q.replace("embla", "Embla") + .replace("miðeind", "Miðeind") + .replace("Guðni Th ", "Guðni Th. ") # By presidential request :) + ) + + def lowercase_beautified_query(self) -> None: + """If we know that no uppercase words occur in the query, + except the initial capital, this function can be called + to adjust the beautified query string accordingly.""" + self.set_beautified_query(self._beautified_query.capitalize()) + + def query_is_command(self) -> None: + """Called from a query processor if the query is a command, not a question""" + # Put a period at the end of the beautified query text + # instead of a question mark + if self._beautified_query.endswith("?"): + self._beautified_query = self._beautified_query[:-1] + "." + + @property + def expires(self) -> Optional[datetime]: + """Expiration time stamp for this query answer, if any""" + return self._expires + + def set_expires(self, ts: datetime) -> None: + """Set an expiration time stamp for this query answer""" + self._expires = ts + + @property + def url(self) -> Optional[str]: + """URL answer associated with this query""" + return self._url + + def set_url(self, u: Optional[str]) -> None: + """Set the URL answer associated with this query""" + self._url = u + + @property + def command(self) -> Optional[str]: + """JavaScript command associated with this query""" + return self._command + + def set_command(self, c: str) -> None: + """Set the JavaScript command associated with this query""" + self._command = c + + @property + def image(self) -> Optional[str]: + """Image URL associated with this query""" + return self._image + + def set_image(self, url: str) -> None: + """Set the image URL command associated with this query""" + self._image = url + + @property + def source(self) -> Optional[str]: + """Return the source of the answer to this query""" + return self._source + + def set_source(self, s: str) -> None: + """Set the source for the answer to this query""" + self._source = s + + @property + def location(self) -> Optional[LatLonTuple]: + """The client location, if known, as a (lat, lon) tuple""" + return self._location + + @property + def token_list(self) -> Optional[List[Tok]]: + """The original token list for the query""" + return self._toklist + + def qtype(self) -> Optional[str]: + """Return the query type""" + return self._qtype + + def ban_nonterminal(self, nonterminal: str) -> None: + """ + Add a nonterminal to the set of + banned nonterminals for this query. + """ + assert nonterminal in self._parser._grammar.nonterminals, ( + f"ban_nonterminal: {nonterminal} doesn't " + "correspond to a nonterminal in the grammar" + ) + self._banned_nonterminals.add(nonterminal) + + def ban_nonterminal_set(self, nonterminals: Set[str]) -> None: + """ + Add a set of nonterminals to the set of + banned nonterminals for this query. + """ + diff = nonterminals.difference(self._parser._grammar.nonterminals) + assert len(diff) == 0, ( + f"ban_nonterminals: nonterminals {diff} don't " + "correspond to a nonterminal in the grammar" + ) + self._banned_nonterminals.update(nonterminals) + + def is_nonterminal_banned(self, nt: Nonterminal) -> bool: + return str(nt) in self._banned_nonterminals + + def set_qtype(self, qtype: str) -> None: + """Set the query type ('Person', 'Title', 'Company', 'Entity'...)""" + self._qtype = qtype + + def set_answer( + self, response: ResponseType, answer: str, voice_answer: Optional[str] = None + ) -> None: + """Set the answer to the query""" + # Detailed response (this is usually a dict) + self._response = response + # Single best answer, as a displayable string + self._answer = answer + # A voice version of the single best answer + self._voice_answer = voice_answer + + def set_key(self, key: str) -> None: + """Set the query key, i.e. the term or string used to execute the query""" + # This is for instance a person name in nominative case + self._key = key + + def set_error(self, error: str) -> None: + """Set an error result""" + self._error = error + + @property + def is_voice(self) -> bool: + """Return True if this is a voice query""" + return self._voice + + @property + def client_id(self) -> Optional[str]: + return self._client_id + + @property + def client_type(self) -> Optional[str]: + """Return client type string, e.g. "ios", "android", "www", etc.""" + return self._client_type + + @property + def client_version(self) -> Optional[str]: + """Return client version string, e.g. "1.0.3" """ + return self._client_version + + @staticmethod + def get_dsm(result: Result) -> DSM: + """Fetch DialogueStateManager instance from result object""" + dsm = cast(QueryStateDict, result.state)["query"].dsm + return dsm + + @property + def dsm(self) -> DSM: + return self._dsm + + def response(self) -> Optional[ResponseType]: + """Return the detailed query answer""" + return self._response + + def answer(self) -> Optional[str]: + """Return the 'single best' displayable query answer""" + return self._answer + + def voice_answer(self) -> str: + """Return a voice version of the 'single best' answer, if any""" + va = self._voice_answer + if va is None: + return "" + # TODO: Replace acronyms with pronounced characters + # (ASÍ -> a ess í, BHM -> bé há emm) + return va + + def key(self) -> Optional[str]: + """Return the query key""" + return self._key + + def error(self) -> Optional[str]: + """Return the query error, if any""" + return self._error + + @property + def context(self) -> Optional[ContextDict]: + """Return the context that has been set by self.set_context()""" + return self._context + + def set_context(self, ctx: ContextDict) -> None: + """Set a query context that will be stored and made available + to the next query from the same client""" + self._context = ctx + + def client_data(self, key: str) -> Optional[ClientDataDict]: + """Fetch client_id-associated data stored in the querydata table""" + if not self.client_id: + return None + with SessionContext(read_only=True) as session: + try: + client_data = ( + session.query(QueryData) + .filter(QueryData.key == key) + .filter(QueryData.client_id == self.client_id) + ).one_or_none() + return ( + None + if client_data is None + else cast(ClientDataDict, client_data.data) + ) + except Exception as e: + logging.error( + "Error fetching client '{0}' query data for key '{1}' from db: {2}".format( + self.client_id, key, e + ) + ) + return None + + @staticmethod + # TODO: This is a hack to get the query data for a specific device to render connected iot devices + def get_client_data(client_id: str, key: str) -> Optional[ClientDataDict]: + """Fetch client_id-associated data stored in the querydata table""" + with SessionContext(read_only=True) as session: + try: + client_data = ( + session.query(QueryData) + .filter(QueryData.key == key) + .filter(QueryData.client_id == client_id) + ).one_or_none() + return ( + None + if client_data is None + else cast(ClientDataDict, client_data.data) + ) + except Exception as e: + logging.error( + "Error fetching client '{0}' query data for key '{1}' from db: {2}".format( + client_id, key, e + ) + ) + return None + + def set_client_data( + self, key: str, data: ClientDataDict, *, update_in_place: bool = False + ) -> None: + """Setter for client query data""" + if not self.client_id or not key: + logging.warning("Couldn't save query data, no client ID or key") + return + Query.store_query_data( + self.client_id, key, data, update_in_place=update_in_place + ) + + @staticmethod + def store_query_data( + client_id: str, key: str, data: ClientDataDict, *, update_in_place: bool = False + ) -> bool: + """Save client query data in the database, under the given key""" + if not client_id or not key: + return False + now = datetime.utcnow() + try: + with SessionContext(commit=True) as session: + row = ( + session.query(QueryData) + .filter(QueryData.key == key) + .filter(QueryData.client_id == client_id) + ).one_or_none() + if row is None: + # Not already present: insert + row = QueryData( + client_id=client_id, + key=key, + created=now, + modified=now, + data=data, + ) + session.add(row) + else: + if update_in_place: + stored_data = deepcopy(row.data) + data = _merge_two_dicts(stored_data, data) + # Already present: update + row.data = data # type: ignore + row.modified = now # type: ignore + # The session is auto-committed upon exit from the context manager + return True + except Exception as e: + logging.error("Error storing query data in db: {0}".format(e)) + return False + + def in_dialogue(self, dialogue_name: str) -> bool: + return self._dsm.active_dialogue == dialogue_name + + @staticmethod + def delete_iot_data(client_id: str, iot_group: str, iot_name: str) -> bool: + """Delete iot data for specific iot_group and iot_name for a client""" + if not client_id or not iot_group or not iot_name: + return False + try: + with SessionContext(commit=True) as session: + rows = ( + session.query(QueryData) + .filter(QueryData.client_id == client_id) + .filter(QueryData.key == "iot") + # .filter(QueryData.data.contains(iot_group)) + # .filter(QueryData.data.contains(iot_name)) + ).all() + for row in rows: + iot_dict = row.data + if iot_group in iot_dict: + if iot_name in iot_dict[iot_group]: + del iot_dict[iot_group][iot_name] + if not iot_dict[iot_group]: + del iot_dict[iot_group] + if not iot_dict: + session.delete(row) + Query.store_query_data(client_id, "iot", iot_dict) + return True + except Exception as e: + logging.error("Error deleting iot data from db: {0}".format(e)) + return False + + @classmethod + def try_to_help(cls, query: str, result: ResponseDict) -> None: + """Attempt to help the user in the case of a failed query, + based on lemmas in the query string""" + # Collect a set of lemmas that occur in the query string + lemmas = set() + with GreynirBin.get_db() as db: + for token in query.lower().split(): + if token.isalpha(): + m = db.meanings(token) + if not m: + # Try an uppercase version, just in case (pun intended) + m = db.meanings(token.capitalize()) + if m: + lemmas |= set(mm.stofn.lower().replace("-", "") for mm in m) + # Collect a list of potential help text functions from the query modules + help_text_funcs: List[Tuple[str, HelpFunc]] = [] + for lemma in lemmas: + help_text_funcs.extend( + [ + (lemma, help_text_func) + for help_text_func in cls._help_texts.get(lemma, []) + ] + ) + if help_text_funcs: + # Found at least one help text func matching a lemma in the query + # Select a function at random and invoke it with the matched + # lemma as a parameter + lemma, help_text_func = random.choice(help_text_funcs) + result["answer"] = result["voice"] = help_text_func(lemma) + result["valid"] = True + + def execute(self) -> ResponseDict: + """Check whether the parse tree is describes a query, and if so, + execute the query, store the query answer in the result dictionary + and return True""" + if Query._parser is None: + Query.init_class() + # By default, the result object contains the 'raw' query + # string (the one returned from the speech-to-text processor) + # as well as the beautified version of that string - which + # usually starts with an uppercase letter and has a trailing + # question mark (or other ending punctuation). + result: ResponseDict = dict(q_raw=self.query, q=self.beautified_query) + # First, try to handle this from plain text, without parsing: + # shortcut to a successful, plain response + if not self.execute_from_plain_text(): + if not self.parse(result): + # Unable to parse the query + err = self.error() + if err is not None: + if Settings.DEBUG: + print("Unable to parse query, error {0}".format(err)) + result["error"] = err + result["valid"] = False + return result + if not self.execute_from_tree(): + # This is a query, but its execution failed for some reason: + # return the error + # if Settings.DEBUG: + # print("Unable to execute query, error {0}".format(q.error())) + result["error"] = self.error() or "E_UNABLE_TO_EXECUTE_QUERY" + result["valid"] = True + return result + # Successful query: return the answer in response + if self._answer: + result["answer"] = self._answer + if self._voice: + # This is a voice query and we have a voice answer to it + va = self.voice_answer() + if va: + result["voice"] = va + if self._voice: + # Optimize the response to voice queries: + # we don't need detailed information about alternative + # answers or their sources + result["response"] = dict(answer=self._answer or "") + elif self._response: + # Return a detailed response if not a voice query + result["response"] = self._response + # Re-assign the beautified query string, in case the query processor modified it + result["q"] = self.beautified_query + # ...and the query type, as a string ('Person', 'Entity', 'Title' etc.) + qt = self.qtype() + if qt: + result["qtype"] = qt + # ...and the key used to retrieve the answer, if any + key = self.key() + if key: + result["key"] = key + # ...and a URL, if any has been set by the query processor + if self.url: + result["open_url"] = self.url + # ...and a command, if any has been set + if self.command: + result["command"] = self.command + # ...image URL, if any + if self.image: + result["image"] = self.image + # .. and the source, if set by query processor + if self.source: + result["source"] = self.source + key = self.key() + if not self._voice and qt == "Person" and key is not None: + # For a person query, add an image (if available) + img = get_image_url(key, enclosing_session=self._session) + if img is not None: + result["image"] = dict( + src=img.src, + width=img.width, + height=img.height, + link=img.link, + origin=img.origin, + name=img.name, + ) + result["valid"] = True + if Settings.DEBUG: + # Dump query results to the console + def converter(o: object): + """Ensure that datetime is output in ISO format to JSON""" + if isinstance(o, datetime): + return o.isoformat()[0:16] + return None + + print( + json.dumps( + { + k: (f"{v[:100]} ... {v[-100:]}") + if isinstance(v, str) and len(v) > 1000 + else v + for k, v in result.items() + # This ^ is just to shorten very long lines + }, + indent=3, + ensure_ascii=False, + default=converter, + ) + ) + return result + + +def _to_case( + np: str, + lookup_func: LookupFunc, + cast_func: CastFunc, + filter_func: Optional[BinFilterFunc], +) -> str: + """Return the noun phrase after casting it from nominative to accusative case""" + # Split the phrase into words and punctuation, respectively + a = re.split(r"([\w]+)", np) + seen_preposition = False + # Enumerate through the 'tokens' + for ix, w in enumerate(a): + if not w: + continue + if w == "- ": + # Something like 'Skeiða- og Hrunamannavegur' + continue + if w.strip() in {"-", "/"}: + # Reset the seen_preposition flag after seeing a hyphen or slash + seen_preposition = False + continue + if seen_preposition: + continue + if re.match(r"^[\w]+$", w): + # This is a word: begin by looking up the word form + _, mm = lookup_func(w) + if not mm: + # Unknown word form: leave it as-is + continue + if any(m.ordfl == "fs" for m in mm): + # Probably a preposition: don't modify it, but + # stop casting until the end of this phrase + seen_preposition = True + continue + # Cast the word to the case we want + a[ix] = cast_func(w, filter_func=filter_func) + # Reassemble the list of words and punctuation + return "".join(a) + + +def to_accusative(np: str, *, filter_func: Optional[BinFilterFunc] = None) -> str: + """Return the noun phrase after casting it from nominative to accusative case""" + with GreynirBin.get_db() as db: + return _to_case( + np, + db.lookup_g, + db.cast_to_accusative, + filter_func=filter_func, + ) + + +def to_dative(np: str, *, filter_func: Optional[BinFilterFunc] = None) -> str: + """Return the noun phrase after casting it from nominative to dative case""" + with GreynirBin.get_db() as db: + return _to_case( + np, + db.lookup_g, + db.cast_to_dative, + filter_func=filter_func, + ) + + +def to_genitive(np: str, *, filter_func: Optional[BinFilterFunc] = None) -> str: + """Return the noun phrase after casting it from nominative to genitive case""" + with GreynirBin.get_db() as db: + return _to_case( + np, + db.lookup_g, + db.cast_to_genitive, + filter_func=filter_func, + ) + + +def process_query( + q: Union[str, Iterable[str]], + voice: bool, + *, + auto_uppercase: bool = False, + location: Optional[LatLonTuple] = None, + remote_addr: Optional[str] = None, + client_id: Optional[str] = None, + client_type: Optional[str] = None, + client_version: Optional[str] = None, + bypass_cache: bool = False, + private: bool = False, +) -> ResponseDict: + + """Process an incoming natural language query. + If voice is True, return a voice-friendly string to + be spoken to the user. If auto_uppercase is True, + the string probably came from voice input and we + need to intelligently guess which words in the query + should be upper case (to the extent that it matters). + The q parameter can either be a single query string + or an iterable of strings that will be processed in + order until a successful one is found.""" + + now = datetime.utcnow() + result: ResponseDict = dict() + client_id = client_id[:256] if client_id else None + first_clean_q: Optional[str] = None + first_qtext = "" + + with SessionContext(commit=True) as session: + + it: Iterable[str] + if isinstance(q, str): + # This is a single string + it = [q] + else: + # This should be an array of strings, + # in decreasing priority order + it = q + + # Iterate through the submitted query strings, + # assuming that they are in decreasing order of probability, + # attempting to execute them in turn until we find + # one that works (or we're stumped) + for qtext in it: + + qtext = qtext.strip() + clean_q = qtext.rstrip("?") + if first_clean_q is None: + # Store the first (most likely) query string + # that comes in from the speech-to-text processor, + # since we want to return that one to the client + # if no query string is matched - not the last + # (least likely) query string + first_clean_q = clean_q + first_qtext = qtext + + # First, look in the query cache for the same question + # (in lower case), having a not-expired answer + cached_answer = None + if voice and not bypass_cache: + # Only use the cache for voice queries + # (handling detailed responses in other queries + # is too much for the cache) + cached_answer = ( + session.query(QueryRow) + .filter(QueryRow.question_lc == clean_q.lower()) + .filter(QueryRow.expires >= now) + .order_by(desc(QueryRow.expires)) + .limit(1) + .one_or_none() + ) + if cached_answer is not None: + # The same question is found in the cache and has not expired: + # return the previous answer + a = cached_answer + result = dict( + valid=True, + q_raw=qtext, + q=a.bquestion, + answer=a.answer, + response=dict(answer=a.answer or ""), + voice=a.voice, + expires=a.expires, + qtype=a.qtype, + key=a.key, + ) + # !!! TBD: Log the cached answer as well? + return result + + # The answer is not found in the cache: Handle the query + query = Query( + session, + qtext, + voice, + auto_uppercase, + location, + client_id, + client_type, + client_version, + ) + result = query.execute() + if result["valid"] and "error" not in result: + # Successful: our job is done + if not private: + # If not in private mode, log the result + try: + # Standard query logging + qrow = QueryRow( + timestamp=now, + interpretations=it, + question=clean_q, + # bquestion is the beautified query string + bquestion=result["q"], + answer=result["answer"], + voice=result.get("voice"), + # Only put an expiration on voice queries + expires=query.expires if voice else None, + qtype=result.get("qtype"), + key=result.get("key"), + latitude=None, + longitude=None, + # Client identifier + client_id=client_id[:256] if client_id else None, + client_type=client_type[:80] if client_type else None, + client_version=client_version[:10] + if client_version + else None, + # IP address + remote_addr=remote_addr or None, + # Context dict, stored as JSON, if present + # (set during query execution) + context=query.context, + # All other fields are set to NULL + ) + session.add(qrow) + # Also log anonymised query + session.add(QueryLog.from_Query(qrow)) + except Exception as e: + logging.error("Error logging query: {0}".format(e)) + return result + + # Failed to answer the query, i.e. no query processor + # module was able to parse the query and provide an answer + result = result or dict(valid=False, error="E_NO_RESULT") + if first_clean_q: + # Re-insert the query data from the first (most likely) + # string returned from the speech-to-text processor, + # replacing residual data that otherwise would be there + # from the last (least likely) query string + result["q_raw"] = first_qtext + result["q"] = beautify_query(first_qtext) + # Attempt to include a helpful response in the result + Query.try_to_help(first_clean_q, result) + + # Log the failure + qrow = QueryRow( + timestamp=now, + interpretations=it, + question=first_clean_q, + bquestion=result["q"], + answer=result.get("answer"), + voice=result.get("voice"), + error=result.get("error"), + latitude=location[0] if location else None, + longitude=location[1] if location else None, + # Client identifier + client_id=client_id, + client_type=client_type or None, + client_version=client_version or None, + # IP address + remote_addr=remote_addr or None + # All other fields are set to NULL + ) + session.add(qrow) + # Also log anonymised query + session.add(QueryLog.from_Query(qrow)) + + return result diff --git a/requirements.txt b/requirements.txt index cf380b64..5073eac1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,8 @@ pytz>=2023.3 timezonefinder>=6.2.0 rjsmin>=1.2.1 python-youtube>=0.9.0 +marshmallow>=3.18.0 +tomli >= 1.1.0 ; python_version < "3.11" country-list>=1.0.0 # AWS Polly text-to-speech botocore==1.21.40 diff --git a/resources/iot_supported.toml b/resources/iot_supported.toml new file mode 100644 index 00000000..1bc10f62 --- /dev/null +++ b/resources/iot_supported.toml @@ -0,0 +1,43 @@ + +[connections.philips_hue] +display_name = "Philips Hue Hub" +mdns_name = "_hue._tcp.local" +db_name = "philips_hue" +name = "Hue Hub" +brand = "Philips" +group = "iot_lights" +logo = "https://upload.wikimedia.org/wikipedia/en/a/a1/Philips_hue_logo.png" +# lightbulb_outline_rounded +icon = 0xf854 +webview_home = '{host}/iot/hue-instructions?client_id={client_id}&iot_group=iot_lights&iot_name=philips_hue' +webview_connect = '{host}/iot/hue-connection?client_id={client_id}&request_url={host}' + +[connections.sonos] +display_name = "Sonos" +mdns_name = "_sonos._tcp.local" +db_name = "sonos" +name = "Sonos" +brand = "Sonos Inc." +group = "iot_speakers" +logo = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Sonos-company.png/800px-Sonos-company.png" +# speaker_outlined +icon = 0xf3c1 +webview_home = '{host}/iot/sonos-instructions?client_id={client_id}&iot_group=iot_speakers&iot_name=sonos' +webview_connect = '{host}/iot/sonos-connection?client_id={client_id}&request_url={host}' +connect_url = 'https://api.sonos.com/login/v3/oauth?client_id={api_key}&response_type=code&state={client_id}&scope=playback-control-all&redirect_uri={host}%2Fconnect_sonos.api' +api_key_filename = "SonosKey" + + +# [connections.spotify] +# display_name = "Spotify" +# db_name = "spotify" +# name = "Spotify" +# brand = "Spotify" +# group = "iot_streaming" +# logo = "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Spotify_logo_without_text.svg/168px-Spotify_logo_without_text.svg.png" +# # music_note_outlined +# icon = 0xf1fb +# webview_home = '{host}/iot/spotify-instructions?client_id={client_id}&iot_group=iot_streaming&iot_name=spotify' +# webview_connect = '{host}/iot/spotify-connection?client_id={client_id}&request_url={host}' +# connect_url = 'https://accounts.spotify.com/authorize?client_id={api_key}&response_type=code&redirect_uri={host}%2Fconnect_spotify.api&state={client_id}&scope=user-read-playback-state+user-modify-playback-state+user-read-playback-position+user-read-recently-played+app-remote-control+user-top-read+user-read-currently-playing+playlist-read-private+streaming' +# api_key_filename = "SpotifyKey" diff --git a/routes/api.py b/routes/api.py index 0b2bcc0e..164f39a1 100755 --- a/routes/api.py +++ b/routes/api.py @@ -23,11 +23,18 @@ """ from typing import Dict, Any, Iterable, List, Optional, cast +from typing_extensions import TypedDict from datetime import datetime import logging +from pathlib import Path -from flask import request, abort +try: + import tomllib # type: ignore (module not available in Python <3.11) +except ModuleNotFoundError: + import tomli as tomllib # Used for Python <3.11 + +from flask import render_template, request, abort from flask.wrappers import Response, Request from settings import Settings @@ -53,6 +60,8 @@ from speech.voices import voice_for_locale from queries.util.openai_gpt import summarize from utility import read_txt_api_key, icelandic_asciify +from queries.extras.sonos import SonosClient +from queries.extras.spotify import SpotifyClient from . import routes, better_jsonify, text_from_request, bool_from_request from . import MAX_URL_LENGTH, MAX_UUID_LENGTH @@ -633,21 +642,23 @@ def register_query_data_api(version: int = 1) -> Response: """ Stores or updates query data for the given client ID - Hinrik's comment: - Data format example from js code + Jóhann's comment: + Data format example for IoT device from js code: + { - 'device_id': device_id, - 'key': 'smartlights', + 'client_id': clientID, + 'key': "iot", 'data': { - 'smartlights': { - 'selected_light': 'philips_hue', + 'iot_lights: { 'philips_hue': { - 'username': username, - 'ipAddress': internalipaddress + 'credentials': { + 'username': username, + 'ip_address': IP address, + } } } } - } + }; """ @@ -670,9 +681,178 @@ def register_query_data_api(version: int = 1) -> Response: return better_jsonify(valid=False, errmsg="Missing parameters.") success = QueryObject.store_query_data( - qdata["client_id"], qdata["key"], qdata["data"] + qdata["client_id"], qdata["key"], qdata["data"], update_in_place=True ) if success: return better_jsonify(valid=True, msg="Query data registered") - return better_jsonify(valid=False, errmsg="Error registering query data.") + + +_WAV_MIMETYPES = frozenset(("audio/wav", "audio/x-wav")) + + +@routes.route("/upload_speech_audio.api", methods=["GET"]) +@routes.route("/upload_speech_audio.api/v", methods=["GET"]) +def upload_speech_audio(version: int = 1) -> Response: + """Receives uploaded speech audio for a query.""" + + # This is disabled for now + return better_jsonify(valid=False, errmsg="Not implemented") + + # This code is currently here only for debugging/development purposes + # if not (1 <= version <= 1): + # return better_jsonify(valid=False, errmsg="Unsupported version") + + # file = request.files.get("file") + # if file is not None: + # # file is a Werkzeug FileStorage object + # mimetype = file.content_type + # if mimetype not in _WAV_MIMETYPES: + # return better_jsonify( + # valid=False, reason=f"File type not supported: {mimetype}" + # ) + # try: + # with open("/tmp/myfile.wav", "wb") as f: + # # Writing data to a file + # f.write(file.read()) + # except Exception as e: + # logging.warning("Exception in upload_speech_audio(): {0}".format(e)) + # return better_jsonify(valid=False, reason="Error reading file") + + # return better_jsonify(valid=True, msg="Audio data received") + + +@routes.route("/connect_sonos.api", methods=["GET"]) +@routes.route("/connect_sonos.api/v", methods=["GET", "POST"]) +def sonos_code(version: int = 1) -> str: + """ + API endpoint to connect to Sonos speakers + """ + args = request.args + client_id = args.get("state") + code = args.get("code") + + if client_id and code: + code_dict = {"iot_speakers": {"sonos": {"credentials": {"code": code}}}} + success = QueryObject.store_query_data( + client_id, "iot", code_dict, update_in_place=True + ) + if success: + device_data = code_dict["iot_speakers"] + # Create an instance of the SonosClient class. + # This will automatically create the rest of the credentials needed. + SonosClient(device_data, client_id) + return render_template("iot-connect-success.html", title="Tenging tókst") + return render_template("iot-connect-error.html", title="Tenging mistókst") + + +@routes.route("/connect_spotify.api", methods=["GET"]) +@routes.route("/connect_spotify.api/v", methods=["GET", "POST"]) +def spotify_code(version: int = 1) -> str: + """ + API endpoint to connect Spotify account + """ + print("Spotifs code") + args = request.args + client_id = args.get("state") + code = args.get("code") + code_dict = { + "iot_streaming": {"spotify": {"credentials": {"code": code}}} + } # create a dictonary with the code + if client_id and code: + success = QueryObject.store_query_data( + client_id, "iot", code_dict, update_in_place=True + ) + if success: + device_data = code_dict.get("iot_streaming").get("spotify") + # Create an instance of the SonosClient class. + # This will automatically create the rest of the credentials needed. + SpotifyClient(device_data, client_id) + return render_template("iot-connect-success.html", title="Tenging tókst") + return render_template("iot-connect-error.html", title="Tenging mistókst") + + +# TODO: Finish functionality to delete iot data from database +@routes.route("/delete_iot_data.api", methods=["DELETE"]) +@routes.route("/delete_iot_data.api/v", methods=["DELETE"]) +def delete_iot_data(version: int = 1) -> Response: + """ + API endpoint to delete IoT data + """ + args = request.args + client_id = args.get("client_id") + iot_group = args.get("iot_group") + iot_name = args.get("iot_name") + print("In delete_iot_data") + + if client_id and iot_group and iot_name: + success = QueryObject.delete_iot_data(client_id, iot_group, iot_name) + if success: + return better_jsonify(valid=True, msg="Deleted IoT data") + return better_jsonify(valid=False, errmsg="Error deleting IoT data.") + + +@routes.route("/get_iot_devices.api", methods=["GET"]) +@routes.route("/get_iot_devices.api/v", methods=["GET"]) +def get_iot_devices(version: int = 1) -> Response: + """ + API endpoint to get IoT devices + """ + args = request.args + client_id = args.get("client_id") + + if client_id: + data = QueryObject.get_client_data(client_id, "iot") + print("Data: ", data) + if data: + json = better_jsonify(valid=True, data=data) + return json + print("Error getting IoT devices") + return better_jsonify(valid=False, errmsg="Error getting IoT data.") + + +class IotSupportedTOMLStructure(TypedDict): + """Structure of the iot_supported TOML file.""" + + connections: Dict[str, Dict[str, str]] + + +@routes.route("/get_supported_iot_connections.api", methods=["GET"]) +@routes.route("/get_supported_iot_connections.api/v", methods=["GET"]) +def get_supported_iot_connections(version: int = 1) -> Response: + """ + API endpoint to get supported IOT devices from iot_supported.toml. + Converts it to JSON and puts it in the response body. + """ + args = request.args + client_id: Optional[str] = args.get("client_id") + host: Optional[str] = args.get("host") + + fpath = Path(__file__).parent.parent / "resources" / "iot_supported.toml" + f = fpath.read_text() + + # Read TOML file containing a list of resources for the dialogue + obj: IotSupportedTOMLStructure = tomllib.loads(f) # type: ignore + + if obj: + for connection in obj["connections"].values(): + webview_home = connection["webview_home"] + webview_home = webview_home.format(host=host, client_id=client_id) + connection.update({"webview_home": webview_home}) + webview_connect = connection["webview_connect"] + webview_connect = webview_connect.format(host=host, client_id=client_id) + connection.update({"webview_connect": webview_connect}) + if "connect_url" in connection: + connect_url = connection["connect_url"] + if "api_key_filename" in connection: + api_key_filename: str = connection["api_key_filename"] + api_key = read_api_key(api_key_filename) + connect_url = connect_url.format( + host=host, client_id=client_id, api_key=api_key + ) + else: + connect_url = connect_url.format(host=host, client_id=client_id) + connection.update({"connect_url": connect_url}) + json = better_jsonify(valid=True, data=obj) + return json + return better_jsonify(valid=False, errmsg="Error getting supported IOT devices.") diff --git a/routes/main.py b/routes/main.py index e7270d1f..95102c7e 100755 --- a/routes/main.py +++ b/routes/main.py @@ -22,13 +22,20 @@ """ from typing import Dict, Any, Iterable, List, Optional, Sequence, Tuple, Union, cast +from typing_extensions import TypedDict import platform import sys import random import json +from pathlib import Path from datetime import datetime +try: + import tomllib # type: ignore (module not available in Python <3.11) +except ModuleNotFoundError: + import tomli as tomllib # Used for Python <3.11 + from flask import render_template, request, redirect, url_for import tokenizer @@ -94,6 +101,60 @@ def analysis() -> str: return render_template("analysis.html", title="Málgreining", default_text=txt) +class IotSupportedTOMLStructure(TypedDict): + """Structure of the iot_supported TOML file.""" + + connections: Dict[str, Dict[str, str]] + + +@routes.route("/iot/") +@max_age(seconds=300) +def iot(device: str): + """Handler for device connection views.""" + args = request.args + iot_name: str = args.get("iot_name") + connection_info = {} + if iot_name: + fpath = Path(__file__).parent.parent / "resources" / "iot_supported.toml" + f = fpath.read_text() + + # Read TOML file containing a list of resources for the dialogue + obj: IotSupportedTOMLStructure = tomllib.loads(f) # type: ignore + if obj: + # for (_, connection) in obj["connections"].items(): + connection_info = obj["connections"][iot_name] + print("Route device: ", device) + return render_template(f"{str(device)}.html", **connection_info) + + +@routes.route("/iot-connect-success") +def iot_connect_success(): + """Handler for successful connection view.""" + return render_template("iot-connect-success.html", title="Tenging tókst") + + +@routes.route("/iot-connect-error") +def iot_connect_error(): + """Handler for unsuccessful connection view.""" + return render_template("iot-connect-error.html", title="Tenging mistókst") + + +@routes.route("/correct", methods=["GET", "POST"]) +def correct(): + """Handler for a page for spelling and grammar correction + of user-entered text""" + try: + txt = text_from_request(request, post_field="txt", get_field="txt") + except Exception: + txt = "" + return render_template( + "correct.html", + title="Yfirlestur", + default_text=txt, + supported_mime_types=list(SUPPORTED_DOC_MIMETYPES), + ) + + MAX_SIM_ARTICLES = 10 # Display at most 10 similarity matches diff --git a/static/css/dark.css b/static/css/dark.css new file mode 100644 index 00000000..3df5388c --- /dev/null +++ b/static/css/dark.css @@ -0,0 +1,102 @@ +/* latin-ext */ +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + font-display: block; + src: local('Lato'), + url("Lato-Regular.woff2") format('woff2'), + url("Lato-Regular.ttf") format('truetype'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 700; + font-display: block; + src: local('Lato Bold'), + url("Lato-Bold.woff2") format('woff2'); +} + +@font-face { + font-family: 'Lato'; + font-style: italic; + font-weight: 400; + font-display: block; + src: local('Lato Italic'), + url("Lato-Italic.woff2") format('woff2'); +} + +body { + background-color: #202020; + font-family: "Lato"; + font-size: 21px; + /* text-align: center;*/ + color: #f7f7f7; +} + +a { + color: #E8302C; +} + +h1 { + font-size: 24px; + margin-top: 4px; +} + +h4 { + text-align: center; +} + +.content { + padding-left: 15px; + padding-right: 15px; + max-width: 800px; + margin: auto; + display: inline-block; +} + +.content ul { + list-style-type: none; + padding-left: 0; +} + +.content ul li { + text-align: center; + font-style: italic; +} + +button { + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + background-color: grey; + border: none; + color: white; + padding: 15px 32px; + margin: 30px; + text-align: center; + text-decoration: none; + display: inline-block; + border-radius: 25px; + font-family: 'Lato', sans-serif; + padding: 10px 20px; + box-shadow: 0px 2px 5px -1px rgba(0, 0, 0, 0.3), inset 0px 12px 0px -10px rgba(255, 255, 255, 0.3); + font-size: 19px; + cursor: pointer; +} + +span.ripple { + position: absolute; + border-radius: 50%; + transform: scale(0); + animation: ripple 800ms linear; + background-color: rgba(255, 255, 255, 0.5); +} + +@keyframes ripple { + to { + transform: scale(4); + opacity: 0; + } +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 00000000..78880fbb --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,115 @@ +/* latin-ext */ +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + font-display: block; + src: local('Lato'), + url("Lato-Regular.woff2") format('woff2'), + url("Lato-Regular.ttf") format('truetype'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 700; + font-display: block; + src: local('Lato Bold'), + url("Lato-Bold.woff2") format('woff2'); +} + +@font-face { + font-family: 'Lato'; + font-style: italic; + font-weight: 400; + font-display: block; + src: local('Lato Italic'), + url("Lato-Italic.woff2") format('woff2'); +} + +body { + background-color: rgba(249, 249, 249, 1.0); + font-family: "Lato"; + font-size: 21px; + /* text-align: center;*/ + color: rgba(51, 51, 51, 1.0); +} + +a { + color: #E8302C; +} + +h1 { + font-size: 24px; + margin-top: 4px; +} + +h2 { + font-size: 18px; + margin-top: 4px; +} + +h4 { + text-align: center; +} + +h5 { + text-align: center; +} + +.h2-subsection { + font-size: 22px; +} + +.content { + padding-left: 15px; + padding-right: 15px; + max-width: 800px; + margin: auto; + display: inline-block; +} + +.content ul { + list-style-type: none; + padding-left: 0; +} + +.content ul li { + text-align: center; + font-style: italic; +} + +button { + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + background-color: #D2827F; + border: none; + color: white; + padding: 15px 32px; + margin: 30px; + text-align: center; + text-decoration: none; + display: inline-block; + border-radius: 25px; + font-family: 'Lato', sans-serif; + padding: 10px 20px; + box-shadow: 0px 2px 5px -1px rgba(0, 0, 0, 0.3), inset 0px 12px 0px -10px rgba(255, 255, 255, 0.3); + font-size: 19px; + cursor: pointer; +} + +span.ripple { + position: absolute; + border-radius: 50%; + transform: scale(0); + animation: ripple 800ms linear; + background-color: rgba(255, 255, 255, 0.5); +} + +@keyframes ripple { + to { + transform: scale(4); + opacity: 0; + } +} \ No newline at end of file diff --git a/static/img/checkmark.gif b/static/img/checkmark.gif new file mode 100644 index 00000000..a983c179 Binary files /dev/null and b/static/img/checkmark.gif differ diff --git a/static/img/error_outline.svg b/static/img/error_outline.svg new file mode 100644 index 00000000..e4dd61f4 --- /dev/null +++ b/static/img/error_outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/hue-connection.html b/templates/hue-connection.html new file mode 100644 index 00000000..148e8b84 --- /dev/null +++ b/templates/hue-connection.html @@ -0,0 +1,200 @@ + + + + + + + + + + + Philips Hue + + + + + + +
+

Philips Hue Hub

+

Philips Hue eru meðal vinsælustu snjallljósa heims. + Með því að tengja Philips Hue miðstöðina þína getur þú beðið Emblu um að kveikja og slökka á ljósum, breyta + lit, birtustigi og fleira.

+ +

Leiðbeiningar

+
    +
  1. Tengdu Philips Hue miðstöðina með netsnúru.
  2. +
  3. Gættu þess að miðstöðin sé tengd sama neti og síminn þinn.
  4. +
  5. Ýttu á takkann á Hue miðstöðinni þinni.
  6. +
  7. Veldu „Tengja“ innan 30 sekúndna frá því þú ýttir á takkann.
  8. +
+ + + +
+ + + + + \ No newline at end of file diff --git a/templates/hue-instructions.html b/templates/hue-instructions.html new file mode 100644 index 00000000..3056fd3a --- /dev/null +++ b/templates/hue-instructions.html @@ -0,0 +1,60 @@ +{% extends "iot-info.html" %} + + +{% block content %} +
+

Fyrirspurnaleiðbeiningar

+ +

Embla ræður eins og stendur við eftirfarandi tegundir fyrirspurna fyrir {{ display_name }}. + Þetta er ekki tæmandi listi, þar sem hún skilur einnig ýmis tilbrigði við þessar spurningar.

+ +
Kveikja og slökkva
+ +
    +
  • Kveiktu ljósin á skrifstofunni.
  • +
  • Slökktu á ljósunum í svefnherberginnu.
  • +
  • Kveiktu ljósin frammi í eldhúsi.
  • +
+ +
Hækka og lækka birtu
+ +
    +
  • Hækkaðu birtuna í stofunni.
  • +
  • Gerðu birtu ljóssins í eldhúsinu meiri.
  • +
  • Lækkaðu birtustigið í svefnherberginu.
  • +
  • Láttu birtuna í eldhúsinu vera minni.
  • +
+ +
Breyta lit ljósa
+ +
    +
  • Gerðu lit ljóssins í eldhúsinu grænt.
  • +
  • Gerðu lesstofuna rauða.
  • +
  • Breyttu leslampanum í rauðan lit.
  • +
+ +
Breyta birtustigi
+ +
    +
  • Gerðu baðherbergið dimmara.
  • +
  • Hækkaðu birtustigið í eldhúsinu.
  • +
+ + +
Breyta hitastigi (e. temperature)
+ +
    +
  • Gerðu birtuna í eldhúsinu hlýrri.
  • +
  • Gerðu birtuna á leslampanum kaldari.
  • +
+ +
Senur (e. scenes)
+ +
    +
  • Settu á stemninguna Rómó í svefnherberginu.
  • +
  • Stilltu á Kvöldstund senuna í eldhúsinu.
  • +
  • Gerðu stemninguna í bílskúrnum rómó.
  • +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/iot-connect-error.html b/templates/iot-connect-error.html new file mode 100644 index 00000000..9360ed4b --- /dev/null +++ b/templates/iot-connect-error.html @@ -0,0 +1,84 @@ + + + + + + + + + + + Tenging mistókst + + + + + + +
+ + Tenging mistókst + +

Tenging mistókst.

+

Vinsamlegast reyndu aftur.

+ + + +
+ + + + + + + \ No newline at end of file diff --git a/templates/iot-connect-success.html b/templates/iot-connect-success.html new file mode 100644 index 00000000..aac5a1fe --- /dev/null +++ b/templates/iot-connect-success.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + Tenging tókst + + + + + +
+ + Tenging tókst + +

Tenging tókst!

+ + + +
+ + + + + + + \ No newline at end of file diff --git a/templates/iot-info.html b/templates/iot-info.html new file mode 100644 index 00000000..2034719e --- /dev/null +++ b/templates/iot-info.html @@ -0,0 +1,126 @@ + + + + + + + + + + + {{ iot_name }} upplýsingar + + + + + + +
+ +

{{ display_name }}

+ + + + +
+ +
+ +
+ + + + {% block content %} + {% endblock %} + + + + + \ No newline at end of file diff --git a/templates/sonos-connection.html b/templates/sonos-connection.html new file mode 100644 index 00000000..95c7d014 --- /dev/null +++ b/templates/sonos-connection.html @@ -0,0 +1,83 @@ + + + + + + + + + + + Sonos + + + + + + +
+

Sonos

+

Hinir geysivinsælu Sonos snjallhátalarar eru studdir af raddstýringu Emblu.
+ Með því að tengja Sonos hátalara getur þú beðið Emblu um að kveikja og slökkva á tónlist, setja á + útvarpstöðvar og stjórna hljóðstyrk.

+

Leiðbeiningar

+
    +
  1. Náðu í Sonos appið og tengdu hátalarinn þinn við þráðlausa netið á heimilinu.
  2. +
  3. Gættu þess að hátalarinn og síminn þinn séu tengdir sama neti.
  4. +
  5. Veldu „Tengja“ hér að neðan og skráðu þig inn með Sonos aðgangi þínum.
  6. +
+ + + +
+ + + + + + + \ No newline at end of file diff --git a/templates/sonos-instructions.html b/templates/sonos-instructions.html new file mode 100644 index 00000000..10d02dae --- /dev/null +++ b/templates/sonos-instructions.html @@ -0,0 +1,32 @@ +{% extends "iot-info.html" %} + + +{% block content %} +
+

Fyrirspurnaleiðbeiningar

+ +

Embla ræður eins og stendur við eftirfarandi tegundir fyrirspurna fyrir {{ display_name }}. + Þetta er ekki tæmandi listi, þar sem hún skilur einnig ýmis tilbrigði við þessar spurningar.

+ +

Kveikja og slökkva

+ +
    +
  • Kveiktu á tónlist.
  • +
  • Slökktu á tónlistinni.
  • +
+ +

Útvarp

+ +
    +
  • Stilltu á Rondó.
  • +
  • Settu á Bylgjuna.
  • +
+ +

Breyta hljóðstyrk

+ +
    +
  • Lækkaðu í tónlistinni.
  • +
  • Hækkaðu í útvarpinu.
  • +
+ + {% endblock %} \ No newline at end of file diff --git a/templates/spotify-connection.html b/templates/spotify-connection.html new file mode 100644 index 00000000..2e65e4b1 --- /dev/null +++ b/templates/spotify-connection.html @@ -0,0 +1,79 @@ + + + + + + + + + + + Spotify + + + + + +
+ + +

Spotify

+

Spotify er vinsælasta tónlistarveita heims.
+ Með því að tengja Spotify aðganginn þinn getur þú beðið Emblu um að spila lögin sem þú vilt heyra. +

Leiðbeiningar

+
    +
  1. Veldu „Tengja“ hér að neðan og skráðu þig inn með Spotify aðgangnum þínum
  2. +
+ + +
+ + + + + + + \ No newline at end of file diff --git a/templates/spotify-instructions.html b/templates/spotify-instructions.html new file mode 100644 index 00000000..96128e1c --- /dev/null +++ b/templates/spotify-instructions.html @@ -0,0 +1,17 @@ +{% extends "iot-info.html" %} + + +{% block content %} +
+

Fyrirspurnaleiðbeiningar

+ +

Embla ræður eins og stendur við eftirfarandi tegundir fyrirspurna fyrir {{ display_name }}. + Þetta er ekki tæmandi listi, þar sem hún skilur einnig ýmis tilbrigði við þessar spurningar.

+ +

Eina skipunin sem þú munt nokkurn tímann þurfa

+ +
    +
  • Spilaðu Þorparann með Pálma Gunnarssyni.
  • +
+ + {% endblock %} \ No newline at end of file diff --git a/tests/files/populate_testdb.sql b/tests/files/populate_testdb.sql index 4a1c6b3b..84decb21 100644 --- a/tests/files/populate_testdb.sql +++ b/tests/files/populate_testdb.sql @@ -24,4 +24,5 @@ INSERT INTO queries ("id", "timestamp", "interpretations", "question", "bquestio INSERT INTO querydata ("client_id", "key", "created", "modified", "data") VALUES ('123456789', 'name', '2020-09-26 21:12:56.20056', '2020-09-26 21:21:15.873656', '{"full": "Sveinbjörn Þórðarson", "first": "Sveinbjörn", "gender": "kk"}'), +('QueryTesting123', 'iot', '2022-09-26 21:13:56.20056', '2022-10-26 15:21:15.873656', '{"iot_lights": {"philips_hue": {"credentials": {"username": "DummyData", "ip_address": "127.0.0.1"}}}}'), ('9A30D6B7-F0C9-48CF-A567-4E9E7D8997C5', 'name', '2020-09-26 22:13:11.164278', '2020-09-28 14:50:52.701844', '{"full": "Sveinbjörn Þórðarson", "first": "Sveinbjörn", "gender": "kk"}'); diff --git a/tests/files/smartlights.json b/tests/files/smartlights.json new file mode 100644 index 00000000..5817f861 --- /dev/null +++ b/tests/files/smartlights.json @@ -0,0 +1,344 @@ +{ + "turn_on": [ + "Kveiktu ljósin", + "Kveiktu ljósin fyrir mig", + "Kveiktu ljósin í eldhúsinu", + "Kveiktu ljósin í eldhúsinu fyrir mig", + "Kveiktu ljósið í eldhúsinu", + "Kveiktu ljósið í eldhúsinu fyrir mig", + "Kveiktu öll ljós", + "Kveiktu öll ljós fyrir mig", + "Kveiktu öll ljósin", + "Kveiktu öll ljósin fyrir mig", + "Kveiktu öll ljósin í svefnherberginu", + "Kveiktu öll ljósin í svefnherberginu fyrir mig", + "Kveiktu ljós allsstaðar", + "Kveiktu ljós allsstaðar fyrir mig", + "Kveiktu ljósin allsstaðar", + "Kveiktu ljósin allsstaðar fyrir mig", + "Kveiktu ljósin alls staðar", + "Kveiktu ljósin alls staðar fyrir mig", + "Kveiktu lampann á ganginum", + "Kveiktu lampann á ganginum fyrir mig", + "Kveiktu loftljósin á skrifstofunni", + "Kveiktu loftljósin á skrifstofunni fyrir mig", + "Vinsamlegast kveiktu ljósin", + "Vinsamlegast kveiktu ljósin fyrir mig", + "Vinsamlegast kveiktu ljósin í eldhúsinu", + "Vinsamlegast kveiktu ljósin í eldhúsinu fyrir mig", + "Vinsamlegast kveiktu ljósið í eldhúsinu", + "Vinsamlegast kveiktu ljósið í eldhúsinu fyrir mig", + "Vinsamlegast kveiktu öll ljós", + "Vinsamlegast kveiktu öll ljós fyrir mig", + "Vinsamlegast kveiktu öll ljósin", + "Vinsamlegast kveiktu öll ljósin fyrir mig", + "Vinsamlegast kveiktu öll ljósin í svefnherberginu", + "Vinsamlegast kveiktu öll ljósin í svefnherberginu fyrir mig", + "Vinsamlegast kveiktu ljós allsstaðar", + "Vinsamlegast kveiktu ljós allsstaðar fyrir mig", + "Vinsamlegast kveiktu ljósin allsstaðar", + "Vinsamlegast kveiktu ljósin allsstaðar fyrir mig", + "Vinsamlegast kveiktu ljósin alls staðar", + "Vinsamlegast kveiktu ljósin alls staðar fyrir mig", + "Vinsamlegast kveiktu lampann á ganginum", + "Vinsamlegast kveiktu lampann á ganginum fyrir mig", + "Vinsamlegast kveiktu loftljósin á skrifstofunni", + "Vinsamlega kveiktu loftljósin á skrifstofunni fyrir mig", + "Værirðu til í að kveikja ljósin", + "Værirðu til í að kveikja ljósin fyrir mig", + "Værirðu til í að kveikja ljósin í eldhúsinu", + "Værirðu til í að kveikja ljósin í eldhúsinu fyrir mig", + "Værirðu til í að kveikja ljósið í eldhúsinu", + "Værirðu til í að kveikja ljósið í eldhúsinu fyrir mig", + "Værirðu til í að kveikja öll ljós", + "Værirðu til í að kveikja öll ljós fyrir mig", + "Værirðu til í að kveikja öll ljósin", + "Værirðu til í að kveikja öll ljósin fyrir mig", + "Værirðu til í að kveikja öll ljósin í svefnherberginu", + "Værirðu til í að kveikja öll ljósin í svefnherberginu fyrir mig", + "Værirðu til í að kveikja ljós allsstaðar", + "Værirðu til í að kveikja ljós allsstaðar fyrir mig", + "Værirðu til í að kveikja ljósin allsstaðar", + "Værirðu til í að kveikja ljósin allsstaðar fyrir mig", + "Værirðu til í að kveikja ljósin alls staðar", + "Værirðu til í að kveikja ljósin alls staðar fyrir mig", + "Værirðu til í að kveikja lampann á ganginum", + "Værirðu til í að kveikja lampann á ganginum fyrir mig", + "Værirðu til í að kveikja loftljósin á skrifstofunni", + "Værirðu til í að kveikja loftljósin á skrifstofunni fyrir mig", + "Gætirðu kveikt ljósin", + "Gætirðu kveikt ljósin fyrir mig", + "Gætirðu kveikt ljósin í eldhúsinu", + "Gætirðu kveikt ljósin í eldhúsinu fyrir mig", + "Gætirðu kveikt ljósið í eldhúsinu", + "Gætirðu kveikt ljósið í eldhúsinu fyrir mig", + "Gætirðu kveikt öll ljós", + "Gætirðu kveikt öll ljós fyrir mig", + "Gætirðu kveikt öll ljósin", + "Gætirðu kveikt öll ljósin fyrir mig", + "Gætirðu kveikt öll ljósin í svefnherberginu", + "Gætirðu kveikt öll ljósin í svefnherberginu fyrir mig", + "Gætirðu kveikt ljós allsstaðar", + "Gætirðu kveikt ljós allsstaðar fyrir mig", + "Gætirðu kveikt ljósin allsstaðar", + "Gætirðu kveikt ljósin allsstaðar fyrir mig", + "Gætirðu kveikt ljósin alls staðar", + "Gætirðu kveikt ljósin alls staðar fyrir mig", + "Gætirðu kveikt lampann á ganginum", + "Gætirðu kveikt lampann á ganginum fyrir mig", + "Gætirðu kveikt loftljósin á skrifstofunni", + "Gætirðu nokkuð kveikt loftljósin á skrifstofunni fyrir mig" + ], + "turn_off": [ + "Slökktu ljósin", + "Slökktu ljósin fyrir mig", + "Slökktu ljósin í eldhúsinu", + "Slökktu ljósin í eldhúsinu fyrir mig", + "Slökktu ljósið í eldhúsinu", + "Slökktu ljósið í eldhúsinu fyrir mig", + "Slökktu öll ljós", + "Slökktu öll ljós fyrir mig", + "Slökktu öll ljósin", + "Slökktu öll ljósin fyrir mig", + "Slökktu öll ljósin í svefnherberginu", + "Slökktu öll ljósin í svefnherberginu fyrir mig", + "Slökktu ljós allsstaðar", + "Slökktu ljós allsstaðar fyrir mig", + "Slökktu ljósin allsstaðar", + "Slökktu ljósin allsstaðar fyrir mig", + "Slökktu ljósin alls staðar", + "Slökktu ljósin alls staðar fyrir mig", + "Slökktu lampann á ganginum", + "Slökktu lampann á ganginum fyrir mig", + "Slökktu loftljósin á skrifstofunni", + "Slökktu loftljósin á skrifstofunni fyrir mig", + "Vinsamlegast slökktu ljósin", + "Vinsamlegast slökktu ljósin fyrir mig", + "Vinsamlegast slökktu ljósin í eldhúsinu", + "Vinsamlegast slökktu ljósin í eldhúsinu fyrir mig", + "Vinsamlegast slökktu ljósið í eldhúsinu", + "Vinsamlegast slökktu ljósið í eldhúsinu fyrir mig", + "Vinsamlegast slökktu öll ljós", + "Vinsamlegast slökktu öll ljós fyrir mig", + "Vinsamlegast slökktu öll ljósin", + "Vinsamlegast slökktu öll ljósin fyrir mig", + "Vinsamlegast slökktu öll ljósin í svefnherberginu", + "Vinsamlegast slökktu öll ljósin í svefnherberginu fyrir mig", + "Vinsamlegast slökktu ljós allsstaðar", + "Vinsamlegast slökktu ljós allsstaðar fyrir mig", + "Vinsamlegast slökktu ljósin allsstaðar", + "Vinsamlegast slökktu ljósin allsstaðar fyrir mig", + "Vinsamlegast slökktu ljósin alls staðar", + "Vinsamlegast slökktu ljósin alls staðar fyrir mig", + "Vinsamlegast slökktu lampann á ganginum", + "Vinsamlegast slökktu lampann á ganginum fyrir mig", + "Vinsamlegast slökktu loftljósin á skrifstofunni", + "Vinsamlega slökktu loftljósin á skrifstofunni fyrir mig", + "Værirðu til í að slökkva ljósin", + "Værirðu til í að slökkva ljósin fyrir mig", + "Værirðu til í að slökkva ljósin í eldhúsinu", + "Værirðu til í að slökkva ljósin í eldhúsinu fyrir mig", + "Værirðu til í að slökkva ljósið í eldhúsinu", + "Værirðu til í að slökkva ljósið í eldhúsinu fyrir mig", + "Værirðu til í að slökkva öll ljós", + "Værirðu til í að slökkva öll ljós fyrir mig", + "Værirðu til í að slökkva öll ljósin", + "Værirðu til í að slökkva öll ljósin fyrir mig", + "Værirðu til í að slökkva öll ljósin í svefnherberginu", + "Værirðu til í að slökkva öll ljósin í svefnherberginu fyrir mig", + "Værirðu til í að slökkva ljós allsstaðar", + "Værirðu til í að slökkva ljós allsstaðar fyrir mig", + "Værirðu til í að slökkva ljósin allsstaðar", + "Værirðu til í að slökkva ljósin allsstaðar fyrir mig", + "Værirðu til í að slökkva ljósin alls staðar", + "Værirðu til í að slökkva ljósin alls staðar fyrir mig", + "Værirðu til í að slökkva lampann á ganginum", + "Værirðu til í að slökkva lampann á ganginum fyrir mig", + "Værirðu til í að slökkva loftljósin á skrifstofunni", + "Værirðu til í að slökkva loftljósin á skrifstofunni fyrir mig", + "Gætirðu slökkt ljósin", + "Gætirðu slökkt ljósin fyrir mig", + "Gætirðu slökkt ljósin í eldhúsinu", + "Gætirðu slökkt ljósin í eldhúsinu fyrir mig", + "Gætirðu slökkt ljósið í eldhúsinu", + "Gætirðu slökkt ljósið í eldhúsinu fyrir mig", + "Gætirðu slökkt öll ljós", + "Gætirðu slökkt öll ljós fyrir mig", + "Gætirðu slökkt öll ljósin", + "Gætirðu slökkt öll ljósin fyrir mig", + "Gætirðu slökkt öll ljósin í svefnherberginu", + "Gætirðu slökkt öll ljósin í svefnherberginu fyrir mig", + "Gætirðu slökkt ljós allsstaðar", + "Gætirðu slökkt ljós allsstaðar fyrir mig", + "Gætirðu slökkt ljósin allsstaðar", + "Gætirðu slökkt ljósin allsstaðar fyrir mig", + "Gætirðu slökkt ljósin alls staðar", + "Gætirðu slökkt ljósin alls staðar fyrir mig", + "Gætirðu slökkt lampann á ganginum", + "Gætirðu slökkt lampann á ganginum fyrir mig", + "Gætirðu slökkt loftljósin á skrifstofunni", + "Gætirðu nokkuð slökkt loftljósin á skrifstofunni fyrir mig" + ], + "increase_brightness": [ + "Hækkaðu birtuna í eldhúsinu", + "Hækkaðu birtuna í eldhúsinu fyrir mig", + "Hækkaðu birtuna á ljósinu í eldhúsinu", + "Hækkaðu birtuna á ljósinu í eldhúsinu fyrir mig", + "Hækkaðu birtuna á öllum ljósunum", + "Hækkaðu birtuna á öllum ljósunum fyrir mig", + "Hækkaðu birtuna á öllum ljósum í svefnherberginu", + "Hækkaðu birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Hækkaðu birtuna allsstaðar", + "Hækkaðu birtuna allsstaðar fyrir mig", + "Hækkaðu birtuna alls staðar", + "Hækkaðu birtuna alls staðar fyrir mig", + "Hækkaðu birtuna á lampanum á ganginum", + "Hækkaðu birtu lampans á ganginum fyrir mig", + "Hækkaðu birtu loftljósanna á skrifstofunni", + "Hækkaðu birtuna í loftljósunum á skrifstofunni fyrir mig", + "Vinsamlegast hækkaðu birtuna í eldhúsinu", + "Vinsamlegast hækkaðu birtuna í eldhúsinu fyrir mig", + "Vinsamlegast hækkaðu birtuna á ljósinu í eldhúsinu", + "Vinsamlegast hækkaðu birtuna á ljósinu í eldhúsinu fyrir mig", + "Vinsamlegast hækkaðu birtuna á öllum ljósunum", + "Vinsamlegast hækkaðu birtuna á öllum ljósunum fyrir mig", + "Vinsamlegast hækkaðu birtuna á öllum ljósum í svefnherberginu", + "Vinsamlegast hækkaðu birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Vinsamlegast hækkaðu birtuna allsstaðar", + "Vinsamlegast hækkaðu birtuna allsstaðar fyrir mig", + "Vinsamlegast hækkaðu birtuna alls staðar", + "Vinsamlegast hækkaðu birtuna alls staðar fyrir mig", + "Vinsamlegast hækkaðu birtuna á lampanum á ganginum", + "Vinsamlegast hækkaðu birtu lampans á ganginum fyrir mig", + "Vinsamlegast hækkaðu birtu loftljósanna á skrifstofunni", + "Vinsamlega hækkaðu birtuna í loftljósunum á skrifstofunni fyrir mig", + "Værirðu til í að hækka birtuna í eldhúsinu", + "Værirðu til í að hækka birtuna í eldhúsinu fyrir mig", + "Værirðu til í að hækka birtuna á ljósinu í eldhúsinu", + "Værirðu til í að hækka birtuna á ljósinu í eldhúsinu fyrir mig", + "Værirðu til í að hækka birtuna á öllum ljósunum", + "Værirðu til í að hækka birtuna á öllum ljósunum fyrir mig", + "Værirðu til í að hækka birtuna á öllum ljósum í svefnherberginu", + "Værirðu til í að hækka birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Værirðu til í að hækka birtuna allsstaðar", + "Værirðu til í að hækka birtuna allsstaðar fyrir mig", + "Værirðu til í að hækka birtuna alls staðar", + "Værirðu til í að hækka birtuna alls staðar fyrir mig", + "Værirðu til í að hækka birtuna á lampanum á ganginum", + "Værirðu til í að hækka birtu lampans á ganginum fyrir mig", + "Værirðu til í að hækka birtu loftljósanna á skrifstofunni", + "Værirðu til í að hækka birtuna í loftljósunum á skrifstofunni fyrir mig", + "Gætirðu hækkað birtuna í eldhúsinu", + "Gætirðu hækkað birtuna í eldhúsinu fyrir mig", + "Gætirðu hækkað birtuna á ljósinu í eldhúsinu", + "Gætirðu hækkað birtuna á ljósinu í eldhúsinu fyrir mig", + "Gætirðu hækkað birtuna á öllum ljósunum", + "Gætirðu hækkað birtuna á öllum ljósunum fyrir mig", + "Gætirðu hækkað birtuna á öllum ljósum í svefnherberginu", + "Gætirðu hækkað birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Gætirðu hækkað birtuna allsstaðar", + "Gætirðu hækkað birtuna allsstaðar fyrir mig", + "Gætirðu hækkað birtuna alls staðar", + "Gætirðu hækkað birtuna alls staðar fyrir mig", + "Gætirðu hækkað birtuna á lampanum á ganginum", + "Gætirðu hækkað birtu lampans á ganginum fyrir mig", + "Gætirðu hækkað birtu loftljósanna á skrifstofunni", + "Gætirðu nokkuð hækkað birtuna í loftljósunum á skrifstofunni fyrir mig" + ], + "decrease_brightness": [ + "Lækkaðu birtuna í eldhúsinu", + "Lækkaðu birtuna í eldhúsinu fyrir mig", + "Lækkaðu birtuna á ljósinu í eldhúsinu", + "Lækkaðu birtuna á ljósinu í eldhúsinu fyrir mig", + "Lækkaðu birtuna á öllum ljósunum", + "Lækkaðu birtuna á öllum ljósunum fyrir mig", + "Lækkaðu birtuna á öllum ljósum í svefnherberginu", + "Lækkaðu birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Lækkaðu birtuna allsstaðar", + "Lækkaðu birtuna allsstaðar fyrir mig", + "Lækkaðu birtuna alls staðar", + "Lækkaðu birtuna alls staðar fyrir mig", + "Lækkaðu birtuna á lampanum á ganginum", + "Lækkaðu birtu lampans á ganginum fyrir mig", + "Lækkaðu birtu loftljósanna á skrifstofunni", + "Lækkaðu birtuna í loftljósunum á skrifstofunni fyrir mig", + "Vinsamlegast lækkaðu birtuna í eldhúsinu", + "Vinsamlegast lækkaðu birtuna í eldhúsinu fyrir mig", + "Vinsamlegast lækkaðu birtuna á ljósinu í eldhúsinu", + "Vinsamlegast lækkaðu birtuna á ljósinu í eldhúsinu fyrir mig", + "Vinsamlegast lækkaðu birtuna á öllum ljósunum", + "Vinsamlegast lækkaðu birtuna á öllum ljósunum fyrir mig", + "Vinsamlegast lækkaðu birtuna á öllum ljósum í svefnherberginu", + "Vinsamlegast lækkaðu birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Vinsamlegast lækkaðu birtuna allsstaðar", + "Vinsamlegast lækkaðu birtuna allsstaðar fyrir mig", + "Vinsamlegast lækkaðu birtuna alls staðar", + "Vinsamlegast lækkaðu birtuna alls staðar fyrir mig", + "Vinsamlegast lækkaðu birtuna á lampanum á ganginum", + "Vinsamlegast lækkaðu birtu lampans á ganginum fyrir mig", + "Vinsamlegast lækkaðu birtu loftljósanna á skrifstofunni", + "Vinsamlega lækkaðu birtuna í loftljósunum á skrifstofunni fyrir mig", + "Værirðu til í að lækka birtuna í eldhúsinu", + "Værirðu til í að lækka birtuna í eldhúsinu fyrir mig", + "Værirðu til í að lækka birtuna á ljósinu í eldhúsinu", + "Værirðu til í að lækka birtuna á ljósinu í eldhúsinu fyrir mig", + "Værirðu til í að lækka birtuna á öllum ljósunum", + "Værirðu til í að lækka birtuna á öllum ljósunum fyrir mig", + "Værirðu til í að lækka birtuna á öllum ljósum í svefnherberginu", + "Værirðu til í að lækka birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Værirðu til í að lækka birtuna allsstaðar", + "Værirðu til í að lækka birtuna allsstaðar fyrir mig", + "Værirðu til í að lækka birtuna alls staðar", + "Værirðu til í að lækka birtuna alls staðar fyrir mig", + "Værirðu til í að lækka birtuna á lampanum á ganginum", + "Værirðu til í að lækka birtu lampans á ganginum fyrir mig", + "Værirðu til í að lækka birtu loftljósanna á skrifstofunni", + "Værirðu til í að lækka birtuna í loftljósunum á skrifstofunni fyrir mig", + "Gætirðu lækkað birtuna í eldhúsinu", + "Gætirðu lækkað birtuna í eldhúsinu fyrir mig", + "Gætirðu lækkað birtuna á ljósinu í eldhúsinu", + "Gætirðu lækkað birtuna á ljósinu í eldhúsinu fyrir mig", + "Gætirðu lækkað birtuna á öllum ljósunum", + "Gætirðu lækkað birtuna á öllum ljósunum fyrir mig", + "Gætirðu lækkað birtuna á öllum ljósum í svefnherberginu", + "Gætirðu lækkað birtuna á öllum ljósum í svefnherberginu fyrir mig", + "Gætirðu lækkað birtuna allsstaðar", + "Gætirðu lækkað birtuna allsstaðar fyrir mig", + "Gætirðu lækkað birtuna alls staðar", + "Gætirðu lækkað birtuna alls staðar fyrir mig", + "Gætirðu lækkað birtuna á lampanum á ganginum", + "Gætirðu lækkað birtu lampans á ganginum fyrir mig", + "Gætirðu lækkað birtu loftljósanna á skrifstofunni", + "Gætirðu nokkuð lækkað birtuna í loftljósunum á skrifstofunni fyrir mig" + ], + "change_color": [ + "Eldhúsljós græn", + "Eldhúsljósin græn", + "Fjólublá ljós á ganginum", + "Gerðu lampann grænan", + "Gerðu ljósið í eldhúsinu grænt", + "Gerðu lýsinguna græna í stofunni", + "Geturðu gert loftljósin blá", + "Gætirðu gert loftljósin blá", + "Rautt ljós í eldhúsinu", + "Værirðu nokkuð til í að gera loftljósin blá fyrir mig", + "Værirðu til í að gera loftljósin blá" + ], + "change_scene": [ + "Kveiktu á senunni diskó", + "Kveiktu á diskóstemningu", + "Kveiktu á diskó stemningu", + "Stilltu á senuna rómó", + "Stilltu á rómó senuna", + "Settu á senuna rómó", + "Settu á rómó senuna", + "Kveiktu á senunni diskó í eldhúsinu", + "Kveiktu á diskóstemningu í eldhúsinu", + "Kveiktu á diskó stemningu í eldhúsinu", + "Stilltu á senuna rómó í eldhúsinu", + "Stilltu á rómó senuna í eldhúsinu", + "Stilltu á rómó senuna í eldhúsinu", + "Settu á senuna rómó á ganginum", + "Settu á rómó senuna á skrifstofunni" + ] +} \ No newline at end of file diff --git a/tests/test_queries.py b/tests/test_queries.py index cab7263d..10216443 100755 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -21,15 +21,18 @@ """ -from typing import Dict, Optional, Any +from typing import Dict, List, Optional, Any import re import os import sys import pytest +import json as jsonlib +from pathlib import Path from copy import deepcopy from datetime import datetime, timedelta from urllib.parse import urlencode +from unittest.mock import patch from flask.testing import FlaskClient @@ -1046,6 +1049,46 @@ def test_geography(client: FlaskClient) -> None: assert "Noregi" in json["answer"] +def test_iot(client: FlaskClient) -> None: + json = qmcall( + client, {"q": "breyttu litnum á ljósunum í eldhúsinu í rauðan"}, "IoT" + ) + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "settu á grænan lit í eldhúsinu"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "stilltu lit ljóssins í eldhúsinu á grænan"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "kveiktu á ljósunum í eldhúsinu"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "hækkaðu birtuna í eldhúsinu"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "gerðu meiri birtu í eldhúsinu"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "gerðu eldhúsið bjartara"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "gerðu birtu ljóssins inni í eldhúsi meiri"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + json = qmcall(client, {"q": "slökktu ljósið inni í eldhúsi"}, "IoT") + assert "ég var að kveikja ljósin! " in json["answer"] + + # json = qmcall(client, {"q": "gerðu meiri birtu inni í eldhúsi"}, "IoT") + # assert "ég var að kveikja ljósin! " in json["answer"] + + # json = qmcall(client, {"q": "gerðu ljósið inni í eldhúsi minna bjart"}, "IoT") + # assert "ég var að kveikja ljósin! " in json["answer"] + + # json = qmcall(client, {"q": "gerðu grænt í eldhúsinu"}, "IoT") + # assert "ég var að kveikja ljósin! " in json["answer"] + + @pytest.mark.skipif(not has_ja_api_key(), reason="no Ja.is API key on test server") def test_ja(client: FlaskClient) -> None: """Ja.is module.""" @@ -1373,6 +1416,34 @@ def caseless_in(a: str, b: str) -> bool: assert "2:00" in json["answer"] +def test_smartlights(client: FlaskClient) -> None: + """Smartlights module""" + q_file = Path(__file__).parent.resolve() / "files" / "smartlights.json" + qs = jsonlib.loads(q_file.read_text()) + assert isinstance(qs, dict) + qkey: str + ql: List[str] + for qkey, ql in qs.items(): + for q in ql: + resp = qmcall(client, {"q": q}, "Smartlights") + assert resp["key"] == qkey + + +def test_smartspeakers(client: FlaskClient) -> None: + """Smartspeakers module (mocked SonosClient)""" + # TODO: Add more tests! + with patch("queries.extras.sonos.SonosClient") as SonosMock: + resp = qmcall( + client, + {"q": "Kveiktu á útvarpsstöðinni rondó", "client_id": "9cc79e5f6b7c65c9"}, + "Smartspeakers", + ) + assert resp["key"] == "radio" + SonosMock.assert_called() + name, args, _ = SonosMock.mock_calls[-1] + assert 'play_radio_stream' in name and 'rondo' in args[0] + + def test_special(client: FlaskClient) -> None: """Special module.""" diff --git a/tools/grammar_gen.py b/tools/grammar_gen.py index 036bb040..22940aa1 100755 --- a/tools/grammar_gen.py +++ b/tools/grammar_gen.py @@ -27,7 +27,15 @@ Use --help to see more information on usage. """ -from typing import Callable, Iterable, Iterator, List, Optional, Union +from typing import ( + Callable, + Iterable, + Iterator, + List, + Optional, + Union, + Match, +) import re import sys @@ -35,7 +43,7 @@ from pathlib import Path from functools import lru_cache -from islenska.basics import BinEntry +from islenska.basics import MarkOrder # Hack to make this Python program executable from the tools subdirectory @@ -53,6 +61,7 @@ # TODO: Create random traversal functionality (itertools.dropwhile?) # TODO: Allow replacing special terminals (no, sérnafn, lo, ...) with words +# TODO: Unwrap recursion (for dealing with complex recursive grammar items as in Greynir.grammar) ColorF = Callable[[str], str] _reset: str = "\033[0m" @@ -73,47 +82,6 @@ pink: ColorF = lambda s: f"\033[95m{s}{_reset}" lightcyan: ColorF = lambda s: f"\033[96m{s}{_reset}" -nonverb_variant_order = [ - ("esb", "evb", "fsb", "fvb", "mst", "vb", "sb"), - ("kk", "kvk", "hk"), - ("nf", "þf", "þgf", "ef"), - ("et", "ft"), - ("gr",), - ("0", "1", "2", "3"), -] -verb_variant_order = [ - ("gm", "mm"), - ("lhnt", "nh", "fh", "vh", "bh"), - ("þt", "nt"), - ("1p", "2p", "3p"), - ("et", "ft"), - ("0", "1", "2", "3"), -] -_order_len = max(len(nonverb_variant_order), len(verb_variant_order)) - -_orderings = { - "hk": ( - "NFET", - "ÞFET", - "ÞGFET", - "EFET", - "NFFT", - "ÞFFT", - "ÞGFFT", - "EFFT", - "NFETgr", - "ÞFETgr", - "ÞGFETgr", - "EFETgr", - "NFFTgr", - "ÞFFTgr", - "ÞGFFTgr", - "EFFTgr", - ), -} -# kk = kvk = hk -_orderings["kk"] = _orderings["kvk"] = _orderings["hk"] - # Grammar item type _GIType = Union[Nonterminal, Terminal] # BÍN, for word lookups @@ -122,41 +90,31 @@ # Mebibyte MiB = 1024 * 1024 -# Preamble with a hacke in case we aren't testing a query grammar +# Preamble hack in case we aren't testing a query grammar # (prevents an error in the QueryGrammar class) PREAMBLE = """ -QueryRoot → - Query +QueryRoot → Query Query → "" """ - -def _binentry_to_int(w: BinEntry) -> List[int]: - """Used for pretty ordering of variants in output :).""" - try: - return [_orderings[w.ofl].index(w.mark)] - except (KeyError, ValueError): - pass - - # Fallback, manually compute order - val = [0 for _ in range(_order_len)] - if w.ofl == "so": - var_order = verb_variant_order - else: - var_order = nonverb_variant_order - - for x, v_list in enumerate(var_order): - for y, v in enumerate(v_list): - if v in w.mark.casefold(): - val[-x] = y + 1 - break - return val - - # Word categories which should have some variant specified -_STRICT_CATEGORIES = frozenset(("no", "so", "lo")) +_STRICT_CATEGORIES = frozenset( + ( + "no", + "kk", + "kvk", + "hk", + "so", + "lo", + "fn", + "pfn", + "gr", + "rt", + "to", + ) +) @lru_cache(maxsize=500) # VERY useful cache @@ -167,51 +125,36 @@ def get_wordform(gi: BIN_LiteralTerminal) -> str: """ global strict word, cat, variants = gi.first, gi.category, "".join(gi.variants).casefold() - ll = BIN.lookup_lemmas(word) + bin_entries = BIN.lookup_lemmas(word)[1] if strict: # Strictness checks on usage of # single-quoted terminals in the grammar - assert len(ll[1]) > 0, f"Meaning not found, use root of word for: {gi.name}" + assert ( + len(bin_entries) > 0 + ), f"Meaning not found, use root of word for: {gi.name}" assert ( cat is not None ), f"Specify category for single quoted terminal: {gi.name}" # Filter by word category - assert len(list(filter(lambda m: m.ofl == cat, ll[1]))) < 2, ( + assert len(list(filter(lambda m: m.ofl == cat, bin_entries))) < 2, ( "Category not specific enough, " "single quoted terminal has " - f"multiple meanings: {gi.name}" + f"multiple possible categories: {gi.name}" ) if cat in _STRICT_CATEGORIES: assert ( len(variants) > 0 - ), f"Specify variant for single quoted terminal: {gi.name}" - else: - assert len(variants) == 0, f"Too many variants for atviksorð: {gi.name}" + ), f"Specify variants for single quoted terminal: {gi.name}" - if not cat and len(ll[1]) > 0: + if not cat and len(bin_entries) > 0: # Guess category from lemma lookup - cat = ll[1][0].ofl - - # Have correct order of variants for form lookup (otherwise it doesn't work) - spec: List[str] = ["" for _ in range(_order_len)] - if cat == "so": - # Verb variants - var_order = verb_variant_order - else: - # Nonverb variants - var_order = nonverb_variant_order + cat = bin_entries[0].ofl - # Re-order correctly - for i, v_list in enumerate(var_order): - for v in v_list: - if v in variants: - spec[i] = v - - wordforms = BIN.lookup_forms( + wordforms = BIN.lookup_variants( word, - cat or None, # type: ignore - "".join(spec), + cat or "", + variants or "", ) if len(wordforms) == 0: @@ -227,19 +170,51 @@ def get_wordform(gi: BIN_LiteralTerminal) -> str: # author of grammar should maybe use double-quotes instead return lightred(f"({'|'.join(wf.bmynd for wf in wordforms)})") - # Sort wordforms in a logical order - wordforms.sort(key=_binentry_to_int) + # Sort wordforms in a canonical order + wordforms.sort(key=lambda ks: MarkOrder.index(ks.ofl, ks.mark)) # Join all matched wordforms together (within parenthesis) return lightcyan(f"({'|'.join(wf.bmynd for wf in wordforms)})") +def _break_up_line(line: List[str], break_indices: List[int]) -> Iterable[List[str]]: + """ + Breaks up a single line containing parenthesized word forms + and yields lines with all combinations of the word forms. + """ + for comb in itertools.product( + *[set(line[i].lstrip("(").rstrip(")").split("|")) for i in break_indices] + ): + yield [ + comb[break_indices.index(i)] if i in break_indices else line[i] + for i in range(len(line)) + ] + + +def expander(it: Iterable[List[str]]) -> Iterable[List[str]]: + """ + Expand lines in iterator that include (word form 1|word form 2|...) items. + """ + for line in it: + paren_indices = [ + i + for i, w in enumerate(line) + if w.startswith("(") and w.endswith(")") + # ^ We can do this as ansi color is disabled when fully expanding lines + ] + if paren_indices: + yield from _break_up_line(line, paren_indices) + else: + yield line + + def generate_from_cfg( grammar: QueryGrammar, *, root: Optional[Union[Nonterminal, str]] = None, depth: Optional[int] = None, n: Optional[int] = None, + expand: Optional[bool] = False, ) -> Iterable[str]: """ Generates an iterator of all sentences from @@ -276,7 +251,14 @@ def generate_from_cfg( for pt in grammar.nt_dict[root] ) - # n=None means return all sentences, otherwise return n sentences + if expand: + # Expand condensed lines + # (containing parenthesized word forms) + # into separate lines + iter = expander(iter) + + # n=None means return all sentences, + # otherwise return n sentences iter = itertools.islice(iter, 0, n) return (" ".join(sl) for sl in iter) @@ -310,6 +292,9 @@ def _generate_all( # (note: there are probably more recursive # nonterminals, they can be added here) _RECURSIVE_NT = re.compile(r"^Nl([/_][a-zA-Z0-9]+)*$") +_PLACEHOLDER_RE = re.compile(r"{([\w]+?)}") +_PLACEHOLDER_PREFIX = "GENERATORPLACEHOLDER_" +_PLACEHOLDER_PREFIX_LEN = len(_PLACEHOLDER_PREFIX) def _generate_one( @@ -320,6 +305,9 @@ def _generate_one( # Special handling of Nl nonterminal, # since it is recursive yield [pink(f"<{gi.name}>")] + elif gi.name.startswith(_PLACEHOLDER_PREFIX): + # Placeholder nonterminal (replaces) + yield [blue(f"{{{gi.name[_PLACEHOLDER_PREFIX_LEN:]}}}")] elif isinstance(gi, Nonterminal): if gi.is_optional and gi.name.endswith("*"): # Star nonterminal, signify using brackets and '...' @@ -359,49 +347,58 @@ def _generate_one( ) parser.add_argument( "files", - nargs="+", - help="File/s containing the grammar fragments", + nargs="*", + help="file/s containing the grammar fragments. " + "Always loads Greynir.grammar, so no file has to be specified " + "when generating sentences from Greynir.grammar", ) parser.add_argument( "-r", "--root", default="Query", - help="Root nonterminal to start from", + help="root nonterminal to start from", ) parser.add_argument( "-d", "--depth", type=int, - help="Maximum depth of the generated sentences", + help="maximum depth of the generated sentences", ) parser.add_argument( "-n", "--num", type=int, - help="Maximum number of sentences to generate", + help="maximum number of sentences to generate", + ) + parser.add_argument( + "-e", + "--expand", + action="store_true", + help="expand lines with multiple interpretations into separate lines", ) parser.add_argument( "-s", "--strict", action="store_true", - help="Enable strict mode, adds some opinionated assertions about the grammar", + help="enable strict mode, adds some opinionated assertions about the grammar", ) parser.add_argument( "-c", "--color", action="store_true", - help="Enables colored output (to stdout only)", + help="enables colored output, when not fully " + "expanding lines or writing output to file", ) parser.add_argument( "-o", "--output", type=Path, - help="Write output to file instead of stdout (faster)", + help="write output to file instead of stdout (faster)", ) parser.add_argument( "--force", action="store_true", - help="Forcefully overwrite output file, ignoring any warnings", + help="forcefully overwrite output file, ignoring any warnings", ) parser.add_argument("--max-size", type=int, help="Maximum output filesize in MiB.") args = parser.parse_args() @@ -416,16 +413,16 @@ def _generate_one( if args.output: p = args.output assert isinstance(p, Path) - try: - p.touch(exist_ok=False) # Raise error if we are overwriting a file - except FileExistsError: - if not args.force: - print("Output file already exists!") - exit(1) + if (p.is_file() or p.exists()) and not args.force: + print("Output file already exists!") + exit(1) - if not args.color or p is not None: - # Undefine color functions + # Expand and writing to file disables color + args.color = args.color and not args.expand and p is None + + if not args.color: useless: ColorF = lambda s: s + # Undefine color functions [ bold, black, @@ -446,14 +443,39 @@ def _generate_one( ] = [useless] * 16 grammar_fragments: str = PREAMBLE + + # We replace {...} format strings with a placeholder + placeholder_defs: str = "" + + def placeholder_func(m: Match[str]) -> str: + """ + Replaces {...} format strings in grammar with an empty nonterminal. + We then handle these nonterminals specifically in _generate_one(). + """ + global placeholder_defs + new_nt = f"{_PLACEHOLDER_PREFIX}{m.group(1)}" + # Create empty production for this nonterminal ('keep'-tag just in case) + placeholder_defs += f"\n{new_nt} → ∅\n$tag(keep) {new_nt}\n" + # Replace format string with reference to new nonterminal + return new_nt + for file in [BIN_Parser._GRAMMAR_FILE] + args.files: # type: ignore with open(file, "r") as f: grammar_fragments += "\n" - grammar_fragments += f.read() + grammar_fragments += _PLACEHOLDER_RE.sub(placeholder_func, f.read()) + + # Add all the placeholder nonterminal definitions we added + grammar_fragments += placeholder_defs + if len(args.files) == 0: + # Generate Greynir.grammar by default + grammar_fragments = grammar_fragments.replace('Query → ""', "Query → S0", 1) # Initialize QueryGrammar class from grammar files grammar = QueryGrammar() - grammar.read_from_generator(args.files[0], iter(grammar_fragments.split("\n"))) + grammar.read_from_generator( + args.files[0] if args.files else BIN_Parser._GRAMMAR_FILE, # type: ignore + iter(grammar_fragments.split("\n")), + ) # Create sentence generator g = generate_from_cfg( @@ -461,6 +483,7 @@ def _generate_one( root=args.root, depth=args.depth, n=args.num, + expand=args.expand, ) if p is not None: