Saturday, February 01, 2014

A Drools example with Scala


I've been thinking about using Drools to build up a rule-based query expansion layer. Since we aim to provide semantic search backed by our medical taxonomy, almost all incoming queries are enriched and rewritten to produce results that we think the user will find more useful than plain keyword matches. Each client is different, so over time, we have built up a fairly comprehensive library of query components that aim to address different search intents, which we can use to compose our ultimate query.

In our previous Lucene based search, I had built a platform in which developers could "compose" customized searchers by configuring prebuilt searcher components using Spring XML. The result was a federated search, whereby an incoming query would be rewritten in several different ways and the results layered together.

When we moved to Solr about 3 years ago, we customized Solr (for a customer) to make it do the same thing. While we continue to support this approach, we are gradually trying to move to a single query approach with custom subqueries instead. Different clients may trigger these subqueries in different ways, which is where the rules engine idea (perhaps even database-driven) comes in. By allowing each client to extend a default ruleset specifying under what situations each query component should be invoked, we can achieve functionality similar to the Spring XML approach.

In this post, I attempt to model the automobile driving advisor rules from Dennis Merritt's article "Building Custom Rule Engines" (search for "traffic_light" in the article to see the original ruleset). While the ruleset doesn't seem to have anything in common with what I've been talking about, it uses similar abstractions, so this was a way for me to kick the tires with the setup and make sure it will work. The Drools driven query expansion layer is just the motivation for this work, the code for that would have to remain company confidential.

In any case, the ruleset described in the article is rewritten below using Drools's MVEL dialect. As you can see, there is a one-to-one correspondence between the rules in the article and the rules here.

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// Source: src/main/resources/traffic.drl
package com.mycompany.solr4extras.rqe;

import com.mycompany.solr4extras.rqe.Traffic;
import com.mycompany.solr4extras.rqe.TrafficResponse;
import com.mycompany.solr4extras.rqe.DrivingStyle;

import com.mycompany.solr4extras.rqe.CityLocator;
global CityLocator cityLocator;

import function com.mycompany.solr4extras.rqe.Functions.*;

dialect "mvel"
no-loop

rule "traffic light green"
when
  $traffic : Traffic ( light == "green" )
then
  insertTrafficResponse(kcontext, $traffic, "proceed")
end

rule "traffic light red"
when 
  $traffic : Traffic ( light == "red" )
then
  insertTrafficResponse(kcontext, $traffic, "stop")
end

rule "traffic light yellow and driving crazy"
when 
  $traffic : Traffic ( light == "yellow" )
  DrivingStyle ( style == "crazy" )
then
  insertTrafficResponse(kcontext, $traffic, "accelerate")
end

rule "traffic light yellow and driving sane"
when 
  $traffic : Traffic ( light == "yellow" )
  DrivingStyle ( style == "sane" )
then
  insertTrafficResponse(kcontext, $traffic, "stop")
end

rule "city is Boston"
when
  $traffic : Traffic (eval (cityLocator.city($traffic) == "Boston" ) )
then
  insertDrivingStyle(kcontext, "crazy")
end

rule "city is not Boston"
when
  $traffic : Traffic (eval (cityLocator.city($traffic) != "Boston" ) )
then
  insertDrivingStyle(kcontext, "sane")
end

And here is the Scala part of this ruleset. TrafficRulesTest is a JUnit test which starts up a new KnowledgeBuilder instance backed by the traffic.drl file above, then inserts a Traffic bean with various traffic light conditions and various city IDs into Drool's StatefulKnowledgeSession, and reads back the TrafficResponse (indicating what the driver should do) from the session after the rules have fired.

The case classes Traffic, TrafficResponse and DrivingStyle represent beans that pass information between the rules engine and the Scala code. CityLocator is a global "utility" class that produces a city name from a city ID, and is instantiated and introduced into the session from the Scala code. The Functions object is a holder of functions that are called by the rule consequences that create the TrafficResponse beans.

One useful piece of information is the kcontext object (its a specially named object on the DRL side that holds the RuleContext, from which you can pull out the session and other useful references on the Scala side. I use this feature in both the methods in the Functions object.

1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// Source: src/test/scala/com/mycompany/solr4extras/rqe/TrafficRulesTest.scala
package com.mycompany.solr4extras.rqe

import java.util.Collection
import org.junit.Test
import org.kie.api.io.ResourceType
import org.kie.api.runtime.rule.RuleContext
import org.kie.internal.KnowledgeBaseFactory
import org.kie.internal.builder.KnowledgeBuilderFactory
import org.kie.internal.io.ResourceFactory
import org.kie.internal.runtime.StatefulKnowledgeSession
import scala.collection.JavaConversions._
import org.junit.Assert

class TrafficRulesTest {

  val kbuilder = KnowledgeBuilderFactory
    .newKnowledgeBuilder()
  kbuilder.add(ResourceFactory
    .newClassPathResource("traffic.drl"), 
    ResourceType.DRL)
  if (kbuilder.hasErrors()) {
    throw new RuntimeException(kbuilder
      .getErrors().toString())
  }

  val kbase = KnowledgeBaseFactory.newKnowledgeBase()
  kbase.addKnowledgePackages(
    kbuilder.getKnowledgePackages())

  @Test
  def testRedInBoston(): Unit = {
    val resp = runTest(Traffic("red", 0))
    Assert.assertEquals("stop", resp.action)
  }
  
  @Test
  def testRedInNewYork(): Unit = {
    val resp = runTest(Traffic("red", 1))
    Assert.assertEquals("stop", resp.action)
  }
    
  @Test
  def testGreenInBoston(): Unit = {
    val resp = runTest(Traffic("green", 0))
    Assert.assertEquals("proceed", resp.action)
  }
  
  @Test
  def testGreenInNewYork(): Unit = {
    val resp = runTest(Traffic("green", 1))
    Assert.assertEquals("proceed", resp.action)
  }

  @Test
  def testYellowInBoston(): Unit = {
    val resp = runTest(Traffic("yellow", 0))
    Assert.assertEquals("accelerate", resp.action)
  }
  
  @Test
  def testYellowInNewYork(): Unit = {
    val resp = runTest(Traffic("yellow", 1))
    Assert.assertEquals("stop", resp.action)
  }
  
  def runTest(traffic: Traffic): TrafficResponse = {
    val session = kbase.newStatefulKnowledgeSession()
    session.setGlobal("cityLocator", new CityLocator())
    session.insert(traffic)
    session.fireAllRules()
    val trafficResponse = 
        getResults(session, "TrafficResponse") match {
      case Some(x) => x.asInstanceOf[TrafficResponse]
      case None => null
    }
    session.dispose()
    trafficResponse    
  }
  
  def getResults(sess: StatefulKnowledgeSession,
      className: String): Option[Any] = {
    val fsess = sess.getObjects().filter(o => 
      o.getClass.getName().endsWith(className))
    if (fsess.size > 0) Some(fsess.toList.head)
    else None
  }
}

case class Traffic(light: String, cid: Int)
case class DrivingStyle(style: String)
case class TrafficResponse(action: String)

class CityLocator {
  
  def city(traffic: Traffic): String =
    if (traffic.cid == 0) "Boston"
    else "New York"
}

object Functions {
  
  def insertTrafficResponse(kcontext: RuleContext, 
      traffic: Traffic, 
      action: String): Unit = {
    // create and insert a TrafficResponse bean
    // back into the session
    val sess = kcontext.getKnowledgeRuntime()
      .asInstanceOf[StatefulKnowledgeSession]
    sess.insert(TrafficResponse(action))
    
    // log the step
    val rulename = kcontext.getRule().getName()
    val cityLocator = sess.getGlobal("cityLocator")
      .asInstanceOf[CityLocator]
    val city = cityLocator.city(traffic)
    Console.println("Rule[%s]: Traffic(%s at %s) => %s"
      .format(rulename, traffic.light, city, action))
  }
  
  def insertDrivingStyle(kcontext: RuleContext, 
      driveStyle: String): Unit = {
    val sess = kcontext.getKnowledgeRuntime()
      .asInstanceOf[StatefulKnowledgeSession]
    Console.println("Driving Style: %s"
      .format(driveStyle))
    sess.insert(DrivingStyle(driveStyle))
  }
}

Thats all I have for today, hope you enjoyed reading as much as I did learning about it. Drools is a bit of a niche, and its hard to find much information on how to get started step-by-step with it, but I found the Drools JBoss Rules 5.0 Developer's Guide from PackT publishing very helpful, although I did have to trawl the Drool mailing lists for answers to some of my problems.

No comments:

Post a Comment

Comments are moderated to prevent spam.